nanocodex 0.6.0 → 0.6.4
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/README.md +158 -6
- package/browser/InlineAgent.mjs +2 -0
- package/browser/Transport.d.mts +3 -0
- package/browser/Transport.mjs +1 -0
- package/browser/Voice.d.mts +2 -0
- package/browser/Voice.mjs +12 -6
- package/browser/VoiceSession.mjs +71 -21
- package/browser/WorkerAgent.mjs +2 -1
- package/browser/host.d.mts +7 -0
- package/browser/host.mjs +19 -0
- package/cloud/Decorator.d.mts +9 -0
- package/cloud/Decorator.mjs +9 -0
- package/cloud/Transport.mjs +6 -0
- package/cloud/actions/connection.mjs +3 -0
- package/cloud/actions/connector.d.mts +18 -0
- package/cloud/actions/connector.mjs +22 -0
- package/cloud/actions/index.d.mts +2 -0
- package/cloud/actions/index.mjs +2 -0
- package/cloud/internal.mjs +5 -0
- package/cloud/types.d.mts +2 -0
- package/cloudflare/Agent.mjs +7 -1
- package/cloudflare/egress.d.mts +3 -1
- package/cloudflare/egress.mjs +14 -0
- package/cloudflare/managed-access.d.mts +23 -0
- package/cloudflare/managed-access.mjs +120 -0
- package/internal.mjs +27 -0
- package/managed/Access.d.mts +5 -0
- package/managed/Access.mjs +66 -0
- package/managed/Agent.d.mts +16 -7
- package/managed/Agent.mjs +28 -7
- package/managed/Voice.mjs +1 -0
- package/managed/index.d.mts +1 -0
- package/managed/index.mjs +1 -0
- package/node/host.mjs +12 -0
- package/package.json +11 -2
- package/pkg-node/nanocodex.js +55 -40
- package/pkg-node/nanocodex_bg.wasm.d.ts +3 -3
- package/pkg-web/.nanocodex-bindgen-stamp +1 -1
- package/pkg-web/nanocodex-build.json +1 -1
- package/pkg-web/nanocodex.d.ts +3 -3
- package/pkg-web/nanocodex.js +55 -40
- package/pkg-web/nanocodex_bg.js +55 -40
- package/pkg-web/nanocodex_bg.wasm +0 -0
- package/pkg-web/nanocodex_bg.wasm.d.ts +3 -3
- package/runtime/code-evaluator.worker.mjs +28 -22
- package/runtime/managed-transport.mjs +1 -0
- package/runtime/quickjs-evaluator.mjs +59 -17
- package/runtime/response-controls.mjs +41 -24
- package/runtime/responses-http.mjs +69 -0
- package/tools/browser/accountInfo.mjs +53 -8
- package/tools/browser/browserShell.mjs +2 -2
- package/tools/browser/index.mjs +3 -3
- package/tools/environment.d.mts +34 -0
- package/tools/environment.mjs +59 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { API_CONNECTORS } from "../internal.mjs";
|
|
2
|
+
|
|
3
|
+
const SERVICES = new Set(API_CONNECTORS);
|
|
4
|
+
|
|
5
|
+
/** Calls an opted-in service using the current app grant, without an agent turn. */
|
|
6
|
+
export async function request(client, options) {
|
|
7
|
+
if (!SERVICES.has(options?.connector)) throw new TypeError("Unknown connector API");
|
|
8
|
+
if (typeof options.path !== "string" || !options.path.startsWith("/") || options.path.startsWith("//")) {
|
|
9
|
+
throw new TypeError("Connector requests require a provider-relative path");
|
|
10
|
+
}
|
|
11
|
+
return client.fetch(`/v1/connectors/${options.connector}/request`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "content-type": "application/json", origin: client.appOrigin },
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
path: options.path,
|
|
16
|
+
method: options.method ?? "GET",
|
|
17
|
+
...(options.connectionId === undefined ? {} : { connection_id: options.connectionId }),
|
|
18
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
19
|
+
}),
|
|
20
|
+
signal: options.signal,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * as account from "./account.mjs";
|
|
2
2
|
export * as agent from "./agent.mjs";
|
|
3
|
+
export * as connector from "./connector.mjs";
|
|
4
|
+
export * as connectors from "./connector.mjs";
|
|
3
5
|
export * as connection from "./connection.mjs";
|
|
4
6
|
export * as grant from "./grant.mjs";
|
|
5
7
|
export * as machineUsd from "./machineUsd.mjs";
|
package/cloud/actions/index.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * as account from "./account.mjs";
|
|
2
2
|
export * as agent from "./agent.mjs";
|
|
3
|
+
export * as connector from "./connector.mjs";
|
|
4
|
+
export * as connectors from "./connector.mjs";
|
|
3
5
|
export * as connection from "./connection.mjs";
|
|
4
6
|
export * as grant from "./grant.mjs";
|
|
5
7
|
export * as machineUsd from "./machineUsd.mjs";
|
package/cloud/internal.mjs
CHANGED
|
@@ -12,8 +12,13 @@ const CLOUD_ACCOUNT_PROVIDERS = Object.freeze([
|
|
|
12
12
|
"gcontacts",
|
|
13
13
|
"slack",
|
|
14
14
|
"x",
|
|
15
|
+
"spotify",
|
|
16
|
+
"soundcloud",
|
|
17
|
+
"link",
|
|
15
18
|
"chatgpt",
|
|
16
19
|
]);
|
|
20
|
+
export const API_CONNECTORS = Object.freeze(CLOUD_ACCOUNT_PROVIDERS.filter(provider => provider !== "chatgpt"));
|
|
21
|
+
|
|
17
22
|
const MCP_CONNECTION_ID = /^[A-Za-z0-9_-]{43}$/;
|
|
18
23
|
const AGENT_CONVERSATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
19
24
|
const CATALOG_DIGEST = /^0x[0-9a-f]{64}$/;
|
package/cloud/types.d.mts
CHANGED
package/cloudflare/Agent.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { responseControlsSocket } from "../runtime/response-controls.mjs";
|
|
1
|
+
import { responseControlsBody, responseControlsSocket } from "../runtime/response-controls.mjs";
|
|
2
2
|
import * as HostAgent from "../host/Agent.mjs";
|
|
3
3
|
import {
|
|
4
4
|
CLOUDFLARE_SESSION_RESERVATION,
|
|
@@ -316,6 +316,12 @@ async function createOwned(module, resolved, options, hostAgent, lifecycle) {
|
|
|
316
316
|
const transport = Transport.hostManaged({
|
|
317
317
|
...endpoint,
|
|
318
318
|
websocketPreconnect: true,
|
|
319
|
+
createResponse(url, id, request) {
|
|
320
|
+
return endpoint.createResponse(url, id, {
|
|
321
|
+
...request,
|
|
322
|
+
body: responseControlsBody(request.body, internalRuntime?.responseControls),
|
|
323
|
+
});
|
|
324
|
+
},
|
|
319
325
|
async createWebSocket(url, id, request) {
|
|
320
326
|
try {
|
|
321
327
|
const opened = await endpoint.createWebSocket(url, id, request);
|
package/cloudflare/egress.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
BrowserHttpRequest,
|
|
2
3
|
BrowserWebSocketConnection,
|
|
3
4
|
BrowserWebSocketRequest,
|
|
4
5
|
} from "../browser/host.mjs";
|
|
@@ -23,6 +24,7 @@ export type CloudflareEgressOptions = Readonly<{
|
|
|
23
24
|
export type CloudflareEgressTransportOptions = Readonly<{
|
|
24
25
|
apiBaseUrl: string;
|
|
25
26
|
websocketUrl: string;
|
|
27
|
+
createResponse(endpoint: string, sessionId: string, request: BrowserHttpRequest): Promise<Response>;
|
|
26
28
|
createWebSocket(
|
|
27
29
|
endpoint: string,
|
|
28
30
|
sessionId: string,
|
|
@@ -31,7 +33,7 @@ export type CloudflareEgressTransportOptions = Readonly<{
|
|
|
31
33
|
}>;
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* Creates the brokered WebSocket
|
|
36
|
+
* Creates the brokered WebSocket and streaming HTTPS seams for a managed Cloudflare Worker.
|
|
35
37
|
*
|
|
36
38
|
* The managed Worker supplies only fixed placeholders to `binding`; the
|
|
37
39
|
* separately deployed broker owns and injects the real provider credential.
|
package/cloudflare/egress.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { responsesHttpHeaders } from "../runtime/responses-http.mjs";
|
|
1
2
|
import { cloudflareEgressSubject } from "./egress-subject.mjs";
|
|
2
3
|
|
|
3
4
|
const OPENAI_WEBSOCKET_BETA = "responses_websockets=2026-02-06";
|
|
@@ -14,6 +15,19 @@ export function cloudflareEgress(options) {
|
|
|
14
15
|
return Object.freeze({
|
|
15
16
|
apiBaseUrl: BROKER_API_BASE_URL,
|
|
16
17
|
websocketUrl: BROKER_WEBSOCKET_URL,
|
|
18
|
+
async createResponse(endpoint, sessionId, request) {
|
|
19
|
+
if (request?.authorization !== "host_managed") {
|
|
20
|
+
throw new Error("Cloudflare EGRESS requires Transport.hostManaged authorization");
|
|
21
|
+
}
|
|
22
|
+
const url = exactWebSocketEndpoint(endpoint, BROKER_WEBSOCKET_URL);
|
|
23
|
+
const headers = responsesHttpHeaders("NANOCODEX_PROVIDER_CREDENTIAL", sessionId, {
|
|
24
|
+
threadId: request.threadId, turnState: request.turnState,
|
|
25
|
+
});
|
|
26
|
+
const subject = cloudflareEgressSubject(binding);
|
|
27
|
+
if (subject !== undefined) headers.set("x-nanocodex-subject", subject);
|
|
28
|
+
return binding.fetch(url, { method: "POST", headers, body: request.body,
|
|
29
|
+
signal: request.signal, redirect: "manual" });
|
|
30
|
+
},
|
|
17
31
|
createWebSocket: (endpoint, sessionId, request) =>
|
|
18
32
|
openBrokeredWebSocket(binding, endpoint, sessionId, request),
|
|
19
33
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Credential-bound authority reused for at most 120 seconds; live renewal is separate. */
|
|
2
|
+
export type ManagedAccessPrincipal = Readonly<{
|
|
3
|
+
kind: "account_session" | "api_key" | "connect_grant" | "service";
|
|
4
|
+
userId: string;
|
|
5
|
+
organizationId: string;
|
|
6
|
+
teamId: string;
|
|
7
|
+
authorizationEpoch: number;
|
|
8
|
+
capabilities: readonly string[];
|
|
9
|
+
connectGrant?: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
export type ManagedAccessEnv = { NANOCODEX_ACCESS_SECRET?: string; DEPLOYMENT_SHA?: string };
|
|
12
|
+
export type ManagedAccessClaims<P extends ManagedAccessPrincipal = ManagedAccessPrincipal> = {
|
|
13
|
+
version: 1; audience: string; binding: string; issuedAt: number; expiresAt: number; principal: P;
|
|
14
|
+
};
|
|
15
|
+
export const MANAGED_ACCESS_HEADER: "x-nanocodex-access";
|
|
16
|
+
export const MANAGED_ACCESS_TTL_MS: 120000;
|
|
17
|
+
export function managedAccessRequest(request: Request): boolean;
|
|
18
|
+
export function readManagedAccess<P extends ManagedAccessPrincipal = ManagedAccessPrincipal>(request: Request, env: ManagedAccessEnv, now?: number): Promise<P | undefined>;
|
|
19
|
+
export function createManagedAccessClaims<P extends ManagedAccessPrincipal>(request: Request, principal: P, now?: number): Promise<ManagedAccessClaims<P>>;
|
|
20
|
+
export function signManagedAccessClaims(claims: ManagedAccessClaims, env: ManagedAccessEnv): Promise<string>;
|
|
21
|
+
export function isHandViewerUpgrade(request: Request): boolean;
|
|
22
|
+
export function handRequestFailure(request: Request, principal: ManagedAccessPrincipal): "forbidden" | "forbidden_origin" | undefined;
|
|
23
|
+
export function handBrokerRequest(request: Request, principal: ManagedAccessPrincipal): Request;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const MANAGED_ACCESS_HEADER = "x-nanocodex-access";
|
|
2
|
+
export const MANAGED_ACCESS_TTL_MS = 120_000;
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
let cachedKey;
|
|
5
|
+
|
|
6
|
+
/** Finite agent operations and screen viewer admission. Publishers, renewal and
|
|
7
|
+
* agent streams retain live authentication; a viewer renews live every 10 s. */
|
|
8
|
+
export function managedAccessRequest(request) {
|
|
9
|
+
const path = new URL(request.url).pathname;
|
|
10
|
+
if (path === "/v1/account/hands/view") {
|
|
11
|
+
return request.method === "GET" && request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
12
|
+
}
|
|
13
|
+
if (path === "/v1/account/hands/screens" || path === "/v1/account/hands/ice") {
|
|
14
|
+
return request.method === (path.endsWith("/screens") ? "GET" : "POST")
|
|
15
|
+
&& !request.headers.has("upgrade");
|
|
16
|
+
}
|
|
17
|
+
return /^\/v1\/agents(?:\/|$)/.test(path)
|
|
18
|
+
&& !request.headers.has("upgrade")
|
|
19
|
+
&& !request.headers.get("accept")?.includes("text/event-stream")
|
|
20
|
+
&& !/\/(?:ws|events|tool-host|device-host|sideband)$/.test(path);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function key(secret) {
|
|
24
|
+
if (secret.length < 32) throw new Error("managed_access_not_configured");
|
|
25
|
+
if (cachedKey?.secret !== secret) cachedKey = { secret, promise: crypto.subtle.importKey(
|
|
26
|
+
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"],
|
|
27
|
+
) };
|
|
28
|
+
return cachedKey.promise;
|
|
29
|
+
}
|
|
30
|
+
function encode(bytes) {
|
|
31
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
32
|
+
}
|
|
33
|
+
function decode(value) {
|
|
34
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid_managed_access");
|
|
35
|
+
return Uint8Array.from(atob(value.replaceAll("-", "+").replaceAll("_", "/")), c => c.charCodeAt(0));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bind reuse to the original login/key/grant, including all Connect restrictions. */
|
|
39
|
+
async function credentialBinding(request, kind) {
|
|
40
|
+
const url = new URL(request.url);
|
|
41
|
+
const connect = kind === "connect_grant" && url.origin === "https://nanocodex.internal" && request.headers.has("x-nanocodex-connect-user");
|
|
42
|
+
// Match the live authenticator's first-cookie and whitespace semantics.
|
|
43
|
+
let cookie;
|
|
44
|
+
for (const part of request.headers.get("cookie")?.split(";") ?? []) {
|
|
45
|
+
const separator = part.indexOf("=");
|
|
46
|
+
if (separator >= 0 && part.slice(0, separator).trim() === "nanocodex_account") {
|
|
47
|
+
cookie = part.slice(separator + 1).trim();
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const authorization = request.headers.get("authorization");
|
|
52
|
+
if (!(connect || (kind === "account_session" && cookie) || (kind === "api_key" && authorization))) throw new Error("managed_access_requires_session");
|
|
53
|
+
// Include every authority input so adding a cookie/Connect restriction cannot
|
|
54
|
+
// make a cached API-key principal override the live authenticator's precedence.
|
|
55
|
+
const identity = JSON.stringify({ kind, authorization, cookie: cookie ?? null,
|
|
56
|
+
connect: [...request.headers].filter(([name]) => name.startsWith("x-nanocodex-connect-")).sort() });
|
|
57
|
+
return encode(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(identity))));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readManagedAccess(request, env, now = Date.now()) {
|
|
61
|
+
try {
|
|
62
|
+
if (!managedAccessRequest(request)) return;
|
|
63
|
+
const token = request.headers.get(MANAGED_ACCESS_HEADER);
|
|
64
|
+
if (!token || token.length > 16_384) return;
|
|
65
|
+
const [prefix, payload, signature, extra] = token.split(".");
|
|
66
|
+
if (prefix !== "ncx_access_v1" || !payload || !signature || extra !== undefined) return;
|
|
67
|
+
if (!await crypto.subtle.verify("HMAC", await key(env.NANOCODEX_ACCESS_SECRET ?? ""), decode(signature), encoder.encode(`${prefix}.${payload}`))) return;
|
|
68
|
+
const value = JSON.parse(new TextDecoder().decode(decode(payload)));
|
|
69
|
+
if (value.version !== 1 || value.audience !== new URL(request.url).origin
|
|
70
|
+
|| !Number.isSafeInteger(value.issuedAt) || !Number.isSafeInteger(value.expiresAt)
|
|
71
|
+
|| value.issuedAt > now || value.expiresAt <= now || value.expiresAt <= value.issuedAt
|
|
72
|
+
|| value.expiresAt - value.issuedAt > MANAGED_ACCESS_TTL_MS
|
|
73
|
+
|| !value.principal || !["account_session", "api_key", "connect_grant"].includes(value.principal.kind)
|
|
74
|
+
|| value.binding !== await credentialBinding(request, value.principal.kind)) return;
|
|
75
|
+
return value.principal;
|
|
76
|
+
} catch { return; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Shared issuance keeps the exact audience, credential binding and lifetime wire contract. */
|
|
80
|
+
export async function createManagedAccessClaims(request, principal, now = Date.now()) {
|
|
81
|
+
return { version: 1, audience: new URL(request.url).origin, binding: await credentialBinding(request, principal.kind),
|
|
82
|
+
issuedAt: now, expiresAt: now + MANAGED_ACCESS_TTL_MS, principal };
|
|
83
|
+
}
|
|
84
|
+
export async function signManagedAccessClaims(claims, env) {
|
|
85
|
+
const payload = `ncx_access_v1.${encode(encoder.encode(JSON.stringify(claims)))}`;
|
|
86
|
+
const signature = await crypto.subtle.sign("HMAC", await key(env.NANOCODEX_ACCESS_SECRET ?? ""), encoder.encode(payload));
|
|
87
|
+
return `${payload}.${encode(new Uint8Array(signature))}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isHandViewerUpgrade(request) {
|
|
91
|
+
return request.method === "GET" && new URL(request.url).pathname === "/v1/account/hands/view"
|
|
92
|
+
&& request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Shared account Hand policy, applied after live or signed-snapshot authentication. */
|
|
96
|
+
export function handRequestFailure(request, principal) {
|
|
97
|
+
const url = new URL(request.url);
|
|
98
|
+
if (principal.connectGrant || !principal.capabilities.includes("agents:read")
|
|
99
|
+
|| !principal.capabilities.includes("tools:use")
|
|
100
|
+
|| (url.pathname.endsWith("/host") && !principal.capabilities.includes("agents:write"))) return "forbidden";
|
|
101
|
+
if (principal.kind !== "api_key" && (request.method !== "GET" || request.headers.has("upgrade"))
|
|
102
|
+
&& request.headers.get("origin") !== url.origin) return "forbidden_origin";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Only for an authenticated principal that passed handRequestFailure. */
|
|
106
|
+
export function handBrokerRequest(request, principal) {
|
|
107
|
+
const url = new URL(request.url);
|
|
108
|
+
const headers = new Headers(request.headers);
|
|
109
|
+
headers.delete("x-nanocodex-remote-vm");
|
|
110
|
+
headers.set("x-nanocodex-owner-id", principal.userId);
|
|
111
|
+
headers.set("x-nanocodex-session-organization-id", principal.organizationId);
|
|
112
|
+
headers.set("x-nanocodex-session-team-id", principal.teamId);
|
|
113
|
+
headers.set("x-nanocodex-authorization-epoch", String(principal.authorizationEpoch));
|
|
114
|
+
headers.set("x-nanocodex-capabilities", JSON.stringify(principal.capabilities));
|
|
115
|
+
for (const name of ["x-nanocodex-connect-user", "x-nanocodex-connect-grant-id", "x-nanocodex-connect-capabilities",
|
|
116
|
+
"x-nanocodex-connect-connectors", "x-nanocodex-connect-connector-connections", "x-nanocodex-connect-mcp-ids",
|
|
117
|
+
"x-nanocodex-connect-app-tool-catalog-digest"]) headers.delete(name);
|
|
118
|
+
return new Request(`https://account-tools.internal${url.pathname.slice("/v1/account".length)}${url.search}`,
|
|
119
|
+
new Request(request, { headers }));
|
|
120
|
+
}
|
package/internal.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const activeAgentSessions = new Map();
|
|
|
12
12
|
const pendingCloudflareAgentSessions = new Map();
|
|
13
13
|
const cloudflareAgentSessions = new WeakSet();
|
|
14
14
|
const hostConnections = new Map();
|
|
15
|
+
const hostHttpRequests = new Map();
|
|
15
16
|
const definitionHosts = new Map();
|
|
16
17
|
let nextHostConnection = 1;
|
|
17
18
|
let nextDefinitionHost = 1;
|
|
@@ -445,6 +446,32 @@ export function releaseDefinitionHost(id) {
|
|
|
445
446
|
}
|
|
446
447
|
|
|
447
448
|
const hostBridge = Object.freeze({
|
|
449
|
+
httpOpen(endpoint, apiKey, accountId, fedramp, sessionId, threadId, turnState, body) {
|
|
450
|
+
const host = requiredSessionHost(threadId);
|
|
451
|
+
if (typeof host.httpOpen !== "function") {
|
|
452
|
+
throw JSON.stringify({ kind: "transport", detail: "host HTTPS transport is unavailable", reconnectable: false });
|
|
453
|
+
}
|
|
454
|
+
const local = host.httpOpen(endpoint, apiKey, sessionId, { accountId, fedramp, threadId, turnState }, body);
|
|
455
|
+
const handle = nextHostConnection++;
|
|
456
|
+
hostHttpRequests.set(handle, { host, handle: local });
|
|
457
|
+
return handle;
|
|
458
|
+
},
|
|
459
|
+
async httpReady(handle) {
|
|
460
|
+
const request = hostHttpRequests.get(handle);
|
|
461
|
+
try { return await request.host.httpReady(request.handle); }
|
|
462
|
+
catch (error) { throw JSON.stringify(connectFailure(error)); }
|
|
463
|
+
},
|
|
464
|
+
async httpNext(handle) {
|
|
465
|
+
const request = hostHttpRequests.get(handle);
|
|
466
|
+
try { return await request.host.httpNext(request.handle); }
|
|
467
|
+
catch (error) { throw JSON.stringify(connectFailure(error)); }
|
|
468
|
+
},
|
|
469
|
+
httpClose(handle) {
|
|
470
|
+
const request = hostHttpRequests.get(handle);
|
|
471
|
+
if (!request) return;
|
|
472
|
+
hostHttpRequests.delete(handle);
|
|
473
|
+
request.host.httpClose(request.handle);
|
|
474
|
+
},
|
|
448
475
|
async connect(endpoint, apiKey, accountId, fedramp, sessionId, threadId, turnState) {
|
|
449
476
|
const host = requiredSessionHost(threadId);
|
|
450
477
|
let result;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Reuses short-lived server-issued authorization for finite managed Agent HTTP requests.
|
|
2
|
+
* Retains the original credential and scopes; retries once only on explicit pre-admission rejection.
|
|
3
|
+
* Cache state is bounded, in memory, and separated by origin and credential/grant.
|
|
4
|
+
*/
|
|
5
|
+
export function withManagedAccess(fetch: typeof globalThis.fetch): typeof globalThis.fetch;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const stores = new WeakMap();
|
|
2
|
+
const HEADER = "x-nanocodex-access";
|
|
3
|
+
|
|
4
|
+
/** Reuse finite-request authority across Agent handles for the same client identity. */
|
|
5
|
+
export function managedAccessFetch(fetchImpl, origin, identity = "browser") {
|
|
6
|
+
let store = stores.get(fetchImpl);
|
|
7
|
+
if (!store) stores.set(fetchImpl, store = new Map());
|
|
8
|
+
const key = `${origin}\n${identity}`;
|
|
9
|
+
let state = store.get(key);
|
|
10
|
+
if (!state) {
|
|
11
|
+
if (store.size >= 128) store.delete(store.keys().next().value);
|
|
12
|
+
store.set(key, state = {});
|
|
13
|
+
}
|
|
14
|
+
const clock = () => performance.now();
|
|
15
|
+
function remember(response, began) {
|
|
16
|
+
const token = response.headers.get(HEADER);
|
|
17
|
+
const ttl = Number(response.headers.get("x-nanocodex-access-ttl-ms"));
|
|
18
|
+
if (response.ok && token?.startsWith("ncx_access_v1.") && token.length <= 16_384
|
|
19
|
+
&& Number.isFinite(ttl) && ttl > 0 && ttl <= 120_000) {
|
|
20
|
+
const until = began + ttl;
|
|
21
|
+
if (!state.until || until > state.until) { state.token = token; state.until = until; }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return async (url, init = {}) => {
|
|
25
|
+
const source = url instanceof Request ? url : undefined;
|
|
26
|
+
const target = new URL(source?.url ?? url);
|
|
27
|
+
const headers = new Headers(source?.headers ?? init.headers);
|
|
28
|
+
headers.delete(HEADER);
|
|
29
|
+
const eligible = target.origin === origin && /^\/v1\/agents(?:\/|$)/.test(target.pathname)
|
|
30
|
+
&& !headers.has("upgrade") && !headers.get("accept")?.includes("text/event-stream")
|
|
31
|
+
&& !/\/(?:ws|events|tool-host|device-host|sideband)$/.test(target.pathname);
|
|
32
|
+
const token = eligible && state.until > clock() + 5000 ? state.token : undefined;
|
|
33
|
+
if (token) headers.set(HEADER, token);
|
|
34
|
+
const began = clock();
|
|
35
|
+
const retryRequest = source && token ? source.clone() : undefined;
|
|
36
|
+
let response;
|
|
37
|
+
try {
|
|
38
|
+
response = source ? await fetchImpl(new Request(source, { headers })) : await fetchImpl(url, { ...init, headers });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (retryRequest?.body) void retryRequest.body.cancel().catch(() => {});
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (token && response.status === 401 && response.headers.get("x-nanocodex-access-rejected") === "1") {
|
|
44
|
+
await response.body?.cancel();
|
|
45
|
+
if (state.token === token) { state.token = undefined; state.until = undefined; }
|
|
46
|
+
const retryHeaders = new Headers(headers);
|
|
47
|
+
retryHeaders.delete(HEADER);
|
|
48
|
+
// Rejected by ingress before admission. Preserve the exact body and idempotency key.
|
|
49
|
+
response = retryRequest ? await fetchImpl(new Request(retryRequest, { headers: retryHeaders }))
|
|
50
|
+
: await fetchImpl(url, { ...init, headers: retryHeaders });
|
|
51
|
+
}
|
|
52
|
+
if (retryRequest?.body && !retryRequest.bodyUsed) void retryRequest.body.cancel().catch(() => {});
|
|
53
|
+
remember(response, began);
|
|
54
|
+
return response;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Wrap a managed HTTP transport with bounded, credential-bound access reuse. */
|
|
59
|
+
export function withManagedAccess(fetchImpl) {
|
|
60
|
+
return (input, init) => {
|
|
61
|
+
const request = new Request(input, init);
|
|
62
|
+
const identity = JSON.stringify([...request.headers].filter(([name]) =>
|
|
63
|
+
name === "authorization" || name === "cookie" || name.startsWith("x-nanocodex-connect-")).sort());
|
|
64
|
+
return managedAccessFetch(fetchImpl, new URL(request.url).origin, identity)(request);
|
|
65
|
+
};
|
|
66
|
+
}
|
package/managed/Agent.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RequestOriginContext } from "../tools/environment.mjs";
|
|
1
2
|
import type { Model, PromptInput, ReasoningMode, Thinking, TurnUsage } from "../types.mjs";
|
|
2
3
|
import type { AgentId } from "../runtime/subagents.mjs";
|
|
3
4
|
|
|
@@ -106,6 +107,8 @@ export type Organization = Readonly<{
|
|
|
106
107
|
export type OrganizationUpdate = Readonly<{ name: string | null }>;
|
|
107
108
|
|
|
108
109
|
export type Options = Readonly<{
|
|
110
|
+
/** Descriptive client-reported context; never grants authority or changes execution placement. */
|
|
111
|
+
requestOrigin?: RequestOriginContext;
|
|
109
112
|
/** Managed service origin. Defaults to the current browser origin. */
|
|
110
113
|
baseUrl?: string | URL | undefined;
|
|
111
114
|
/** Server credential. Browsers omit this and authenticate with the account cookie. */
|
|
@@ -141,6 +144,8 @@ export type Environment = Readonly<{
|
|
|
141
144
|
network?: NetworkPolicy;
|
|
142
145
|
}>;
|
|
143
146
|
export type Configuration = Readonly<{
|
|
147
|
+
/** Pin this session to one connected ChatGPT account; disables automatic account failover. */
|
|
148
|
+
chatgpt_account_id?: string;
|
|
144
149
|
settings?: Readonly<{ model: CreateSettings["model"]; thinking: CreateSettings["thinking"]; reasoning_mode: "standard" | "pro"; fast_mode: boolean }>;
|
|
145
150
|
instructions?: string;
|
|
146
151
|
tools?: readonly string[];
|
|
@@ -390,6 +395,9 @@ export type CronTrigger = Readonly<{
|
|
|
390
395
|
}>;
|
|
391
396
|
|
|
392
397
|
export type Agent = Readonly<{
|
|
398
|
+
/** Start bounded background runtime/socket preparation for an active conversation.
|
|
399
|
+
* Resolves on acceptance, not provider readiness. Never required before prompt(). */
|
|
400
|
+
prepare(options?: Readonly<{ signal?: AbortSignal }>): Promise<void>;
|
|
393
401
|
requiredActions: Readonly<{
|
|
394
402
|
list(): Promise<{ data: readonly Readonly<{ call_id: string; session_id: string; source_call_id: string; name: string; input: unknown; deadline_at: number }>[] }>;
|
|
395
403
|
submit(callId: string, outcome: import("nanocodex-tools/hosted").HostedToolCallOutcome): Promise<void>;
|
|
@@ -443,15 +451,16 @@ export function remove(id: string, options?: Options): Promise<void>;
|
|
|
443
451
|
export { remove as delete };
|
|
444
452
|
export function findSessions(request: FindSessionsRequest, options?: Options): Promise<FindSessionsResponse>;
|
|
445
453
|
export function readSession(request: ReadSessionRequest, options?: Options): Promise<ReadSessionResponse>;
|
|
454
|
+
export type MemoryOptions = Options & Readonly<{ scope?: "team" | "personal" }>;
|
|
446
455
|
/** List the authenticated account's hosted durable memory. */
|
|
447
|
-
export function listMemories(options?:
|
|
456
|
+
export function listMemories(options?: MemoryOptions): Promise<readonly MemoryRecord[]>;
|
|
448
457
|
/** Compare-and-swap delete one hosted durable memory; deleting an absent id is idempotent. */
|
|
449
|
-
export function deleteMemory(key: MemoryKey, options?:
|
|
450
|
-
export function memory(operation: MemoryScanOperation, options?:
|
|
451
|
-
export function memory(operation: MemoryReadOperation, options?:
|
|
452
|
-
export function memory(operation: MemoryPutOperation, options?:
|
|
453
|
-
export function memory(operation: MemoryDeleteOperation, options?:
|
|
454
|
-
export function memory(operation: MemoryOperation, options?:
|
|
458
|
+
export function deleteMemory(key: MemoryKey, options?: MemoryOptions): Promise<void>;
|
|
459
|
+
export function memory(operation: MemoryScanOperation, options?: MemoryOptions): Promise<MemoryScanResult>;
|
|
460
|
+
export function memory(operation: MemoryReadOperation, options?: MemoryOptions): Promise<MemoryReadResult>;
|
|
461
|
+
export function memory(operation: MemoryPutOperation, options?: MemoryOptions): Promise<MemoryPutResult>;
|
|
462
|
+
export function memory(operation: MemoryDeleteOperation, options?: MemoryOptions): Promise<MemoryDeleteResult>;
|
|
463
|
+
export function memory(operation: MemoryOperation, options?: MemoryOptions): Promise<MemoryResult>;
|
|
455
464
|
export function getOrganization(options?: Options): Promise<Organization>;
|
|
456
465
|
export function updateOrganization(request: OrganizationUpdate, options?: Options): Promise<Organization>;
|
|
457
466
|
|
package/managed/Agent.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { requestOriginContext } from "../tools/environment.mjs";
|
|
1
2
|
import { ManagedError } from "./ManagedError.mjs";
|
|
2
3
|
import { registerManagedAgent } from "./internal.mjs";
|
|
4
|
+
import { managedAccessFetch } from "./Access.mjs";
|
|
3
5
|
|
|
4
6
|
const API_KEY = /^ncx_live_[A-Za-z0-9_-]{12}_[A-Za-z0-9_-]{43}$/;
|
|
5
7
|
const CURSOR = /^(?:0|[1-9][0-9]*)$/;
|
|
@@ -23,7 +25,7 @@ const TURN_SUBMISSION_TIMEOUT_MS = 10_000;
|
|
|
23
25
|
const TURN_STATE_POLL_INITIAL_MS = 1_000;
|
|
24
26
|
const TURN_STATE_POLL_MAX_MS = 5_000;
|
|
25
27
|
const TURN_STATE_READ_TIMEOUT_MS = 2_000;
|
|
26
|
-
const ALLOWED_OPTIONS = new Set(["apiKey", "baseUrl", "fetch", "toolsTransport"]);
|
|
28
|
+
const ALLOWED_OPTIONS = new Set(["apiKey", "baseUrl", "fetch", "toolsTransport", "requestOrigin"]);
|
|
27
29
|
const CREATE_SETTINGS = new Set(["model", "thinking", "reasoningMode", "fastMode"]);
|
|
28
30
|
const SETTINGS_PATCH = CREATE_SETTINGS;
|
|
29
31
|
const MODELS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra"]);
|
|
@@ -247,9 +249,16 @@ export async function readSession(request, options = {}) {
|
|
|
247
249
|
return managedReadSessionResponse(body);
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
function memoryClient(options) {
|
|
253
|
+
const { scope = "team", ...clientOptions } = options;
|
|
254
|
+
if (scope !== "team" && scope !== "personal") throw new TypeError("memory scope must be team or personal");
|
|
255
|
+
return { client: managedClient(clientOptions), scope };
|
|
256
|
+
}
|
|
257
|
+
|
|
250
258
|
/** List the authenticated account's hosted durable memory. */
|
|
251
259
|
export async function listMemories(options = {}) {
|
|
252
|
-
const
|
|
260
|
+
const { client, scope } = memoryClient(options);
|
|
261
|
+
const body = await client.json(`/v1/memory${scope === "personal" ? "?scope=personal" : ""}`);
|
|
253
262
|
if (!body || typeof body !== "object" || Array.isArray(body) || !Array.isArray(body.memories)) {
|
|
254
263
|
throw new ManagedError("invalid_response", "managed memory list is malformed");
|
|
255
264
|
}
|
|
@@ -259,8 +268,9 @@ export async function listMemories(options = {}) {
|
|
|
259
268
|
/** Compare-and-swap delete one account-owned hosted memory. */
|
|
260
269
|
export async function deleteMemory(key, options = {}) {
|
|
261
270
|
validateMemoryKey(key);
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
const { client, scope } = memoryClient(options);
|
|
272
|
+
await client.empty(
|
|
273
|
+
`/v1/memory/${key.id}?version=${key.version}${scope === "personal" ? "&scope=personal" : ""}`,
|
|
264
274
|
{ method: "DELETE" },
|
|
265
275
|
);
|
|
266
276
|
}
|
|
@@ -268,9 +278,10 @@ export async function deleteMemory(key, options = {}) {
|
|
|
268
278
|
/** Run one atomic durable-memory operation in the authenticated account scope. */
|
|
269
279
|
export async function memory(operation, options = {}) {
|
|
270
280
|
validateMemoryOperation(operation);
|
|
271
|
-
const
|
|
281
|
+
const { client, scope } = memoryClient(options);
|
|
282
|
+
const body = await client.json("/v1/memory", {
|
|
272
283
|
method: "POST",
|
|
273
|
-
body: JSON.stringify(operation),
|
|
284
|
+
body: JSON.stringify(scope === "personal" ? { ...operation, scope } : operation),
|
|
274
285
|
});
|
|
275
286
|
return managedMemoryResponse(body, operation.operation);
|
|
276
287
|
}
|
|
@@ -302,6 +313,10 @@ function agentHandle(client, id, summary, retainedEventStream) {
|
|
|
302
313
|
id,
|
|
303
314
|
...(summary === undefined ? {} : { summary }),
|
|
304
315
|
events,
|
|
316
|
+
// Activation is explicit: passive event/history readers never warm a model.
|
|
317
|
+
prepare: (options = {}) => client.json(`${agentPath(id)}/prepare`, {
|
|
318
|
+
method: "POST", ...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
319
|
+
}).then(() => undefined),
|
|
305
320
|
requiredActions: Object.freeze({
|
|
306
321
|
list: () => client.json(`${agentPath(id)}/required-actions`),
|
|
307
322
|
submit: (callId, outcome) => {
|
|
@@ -1524,6 +1539,11 @@ function managedClient(options) {
|
|
|
1524
1539
|
}
|
|
1525
1540
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
1526
1541
|
if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this runtime");
|
|
1542
|
+
const authorizedFetch = managedAccessFetch(fetchImpl, new URL(baseUrl).origin, apiKey);
|
|
1543
|
+
const requestOrigin = requestOriginContext(options.requestOrigin ?? {
|
|
1544
|
+
client: typeof window === "undefined" ? "javascript" : "web",
|
|
1545
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1546
|
+
});
|
|
1527
1547
|
const toolsTransport = options.toolsTransport;
|
|
1528
1548
|
if (toolsTransport !== undefined
|
|
1529
1549
|
&& typeof toolsTransport !== "function"
|
|
@@ -1533,13 +1553,14 @@ function managedClient(options) {
|
|
|
1533
1553
|
|
|
1534
1554
|
const response = async (path, init = {}) => {
|
|
1535
1555
|
const headers = new Headers();
|
|
1556
|
+
if (path === "/v1/agents" || path.startsWith("/v1/agents/")) headers.set("x-nanocodex-client-context", JSON.stringify(requestOrigin));
|
|
1536
1557
|
if (init.body !== undefined) headers.set("content-type", "application/json");
|
|
1537
1558
|
if (init.accept) headers.set("accept", init.accept);
|
|
1538
1559
|
if (init.idempotencyKey) headers.set("idempotency-key", init.idempotencyKey);
|
|
1539
1560
|
if (init.voiceSessionId) headers.set("x-nanocodex-voice-session-id", init.voiceSessionId);
|
|
1540
1561
|
if (apiKey) headers.set("authorization", `Bearer ${apiKey}`);
|
|
1541
1562
|
try {
|
|
1542
|
-
return await
|
|
1563
|
+
return await authorizedFetch(new URL(path, baseUrl), {
|
|
1543
1564
|
method: init.method ?? "GET",
|
|
1544
1565
|
headers,
|
|
1545
1566
|
credentials: apiKey ? "omit" : "include",
|
package/managed/Voice.mjs
CHANGED
|
@@ -50,6 +50,7 @@ export async function createManagedBrowserVoice(agent, voice, options = {}) {
|
|
|
50
50
|
appendText: (role, text) => raw.appendText(role, text),
|
|
51
51
|
appendContext: (text) => raw.appendContext(text),
|
|
52
52
|
parallelStartup: true,
|
|
53
|
+
dataChannelControl: true,
|
|
53
54
|
async start() {
|
|
54
55
|
const started = await startManagedRealtime(agent, voiceSessionId, startOperationId);
|
|
55
56
|
raw.start(JSON.stringify(started.context));
|
package/managed/index.d.mts
CHANGED
package/managed/index.mjs
CHANGED
package/node/host.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createResponsesHttp, responsesHttpHeaders } from "../runtime/responses-http.mjs";
|
|
1
2
|
import { Console } from "node:console";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { resolve } from "node:path";
|
|
@@ -41,6 +42,12 @@ export function createNodeHost(options = {}) {
|
|
|
41
42
|
}
|
|
42
43
|
const toolsLifecycle = options.tools?.[toolRuntimeLifecycle];
|
|
43
44
|
toolsLifecycle?.available();
|
|
45
|
+
const http = createResponsesHttp((endpoint, apiKey, sessionId, metadata, body, signal) => {
|
|
46
|
+
if (disposal) throw new Error("Nanocodex host is already disposed");
|
|
47
|
+
if (options.mpp) throw JSON.stringify({ kind: "transport", detail: "MPP HTTPS transport is unavailable", reconnectable: false });
|
|
48
|
+
return fetch(endpoint, { method: "POST", headers: responsesHttpHeaders(apiKey, sessionId, metadata),
|
|
49
|
+
body, signal, redirect: "error" });
|
|
50
|
+
});
|
|
44
51
|
const connections = new Map();
|
|
45
52
|
const code = createCodeRuntime(options.tools, {
|
|
46
53
|
require: createRequire(resolve(options.workspace ?? process.cwd(), ".nanocodex-code-mode.cjs")),
|
|
@@ -288,6 +295,7 @@ export function createNodeHost(options = {}) {
|
|
|
288
295
|
|
|
289
296
|
function dispose() {
|
|
290
297
|
if (disposal) return disposal;
|
|
298
|
+
http.dispose();
|
|
291
299
|
disposal = Promise.resolve().then(() => settleCleanup([
|
|
292
300
|
...[...connections.keys()].map((handle) => () => close(handle)),
|
|
293
301
|
() => code.reset(),
|
|
@@ -309,6 +317,10 @@ export function createNodeHost(options = {}) {
|
|
|
309
317
|
if (references > 0) references -= 1;
|
|
310
318
|
return references === 0 ? dispose() : Promise.resolve();
|
|
311
319
|
},
|
|
320
|
+
httpOpen: http.httpOpen,
|
|
321
|
+
httpReady: http.httpReady,
|
|
322
|
+
httpNext: http.httpNext,
|
|
323
|
+
httpClose: http.httpClose,
|
|
312
324
|
connect,
|
|
313
325
|
send,
|
|
314
326
|
next,
|