@open-everywhere/adapters 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +43 -0
- package/agent/aws/cli.ts +121 -0
- package/agent/aws/config.ts +71 -0
- package/agent/aws/register.ts +65 -0
- package/agent/aws/sso.ts +567 -0
- package/agent/aws/sts.ts +191 -0
- package/agent/blob/hosting.ts +24 -0
- package/agent/browser/config.ts +62 -0
- package/agent/browser/register.ts +66 -0
- package/agent/browser/sandbox.ts +39 -0
- package/agent/contributions.ts +12 -0
- package/agent/directory/config.ts +138 -0
- package/agent/directory/okta.ts +226 -0
- package/agent/directory/register.ts +16 -0
- package/agent/instrumentation.ts +42 -0
- package/agent/model/approval-judge.ts +57 -0
- package/agent/model/dream-judge.ts +105 -0
- package/agent/model/gateway-vision.ts +162 -0
- package/agent/model/gateway.ts +85 -0
- package/agent/oe-oauth/config.ts +246 -0
- package/agent/oe-oauth/errors.ts +77 -0
- package/agent/oe-oauth/oauth-client.ts +376 -0
- package/agent/oe-oauth/pkce.ts +21 -0
- package/agent/oe-oauth/register.ts +32 -0
- package/agent/oe-oauth/token-vault.ts +652 -0
- package/agent/oe-oauth/user-auth-flow.ts +76 -0
- package/agent/postgres/neon.ts +108 -0
- package/agent/sandbox/backend-stop.ts +259 -0
- package/agent/sandbox/created-sandbox-registry.ts +27 -0
- package/agent/sandbox/extend-workspace-time.ts +77 -0
- package/agent/sandbox/vercel-lifecycle.ts +252 -0
- package/dist/agent/aws/cli.d.ts +24 -0
- package/dist/agent/aws/cli.d.ts.map +1 -0
- package/dist/agent/aws/cli.js +89 -0
- package/dist/agent/aws/cli.js.map +1 -0
- package/dist/agent/aws/config.d.ts +27 -0
- package/dist/agent/aws/config.d.ts.map +1 -0
- package/dist/agent/aws/config.js +46 -0
- package/dist/agent/aws/config.js.map +1 -0
- package/dist/agent/aws/register.d.ts +23 -0
- package/dist/agent/aws/register.d.ts.map +1 -0
- package/dist/agent/aws/register.js +39 -0
- package/dist/agent/aws/register.js.map +1 -0
- package/dist/agent/aws/sso.d.ts +109 -0
- package/dist/agent/aws/sso.d.ts.map +1 -0
- package/dist/agent/aws/sso.js +421 -0
- package/dist/agent/aws/sso.js.map +1 -0
- package/dist/agent/aws/sts.d.ts +27 -0
- package/dist/agent/aws/sts.d.ts.map +1 -0
- package/dist/agent/aws/sts.js +135 -0
- package/dist/agent/aws/sts.js.map +1 -0
- package/dist/agent/blob/hosting.d.ts +4 -0
- package/dist/agent/blob/hosting.d.ts.map +1 -0
- package/dist/agent/blob/hosting.js +20 -0
- package/dist/agent/blob/hosting.js.map +1 -0
- package/dist/agent/browser/config.d.ts +24 -0
- package/dist/agent/browser/config.d.ts.map +1 -0
- package/dist/agent/browser/config.js +50 -0
- package/dist/agent/browser/config.js.map +1 -0
- package/dist/agent/browser/register.d.ts +7 -0
- package/dist/agent/browser/register.d.ts.map +1 -0
- package/dist/agent/browser/register.js +47 -0
- package/dist/agent/browser/register.js.map +1 -0
- package/dist/agent/browser/sandbox.d.ts +23 -0
- package/dist/agent/browser/sandbox.d.ts.map +1 -0
- package/dist/agent/browser/sandbox.js +27 -0
- package/dist/agent/browser/sandbox.js.map +1 -0
- package/dist/agent/contributions.d.ts +16 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +11 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/directory/config.d.ts +39 -0
- package/dist/agent/directory/config.d.ts.map +1 -0
- package/dist/agent/directory/config.js +101 -0
- package/dist/agent/directory/config.js.map +1 -0
- package/dist/agent/directory/okta.d.ts +19 -0
- package/dist/agent/directory/okta.d.ts.map +1 -0
- package/dist/agent/directory/okta.js +178 -0
- package/dist/agent/directory/okta.js.map +1 -0
- package/dist/agent/directory/register.d.ts +6 -0
- package/dist/agent/directory/register.d.ts.map +1 -0
- package/dist/agent/directory/register.js +16 -0
- package/dist/agent/directory/register.js.map +1 -0
- package/dist/agent/instrumentation.d.ts +5 -0
- package/dist/agent/instrumentation.d.ts.map +1 -0
- package/dist/agent/instrumentation.js +39 -0
- package/dist/agent/instrumentation.js.map +1 -0
- package/dist/agent/model/approval-judge.d.ts +15 -0
- package/dist/agent/model/approval-judge.d.ts.map +1 -0
- package/dist/agent/model/approval-judge.js +46 -0
- package/dist/agent/model/approval-judge.js.map +1 -0
- package/dist/agent/model/dream-judge.d.ts +40 -0
- package/dist/agent/model/dream-judge.d.ts.map +1 -0
- package/dist/agent/model/dream-judge.js +80 -0
- package/dist/agent/model/dream-judge.js.map +1 -0
- package/dist/agent/model/gateway-vision.d.ts +53 -0
- package/dist/agent/model/gateway-vision.d.ts.map +1 -0
- package/dist/agent/model/gateway-vision.js +129 -0
- package/dist/agent/model/gateway-vision.js.map +1 -0
- package/dist/agent/model/gateway.d.ts +30 -0
- package/dist/agent/model/gateway.d.ts.map +1 -0
- package/dist/agent/model/gateway.js +55 -0
- package/dist/agent/model/gateway.js.map +1 -0
- package/dist/agent/oe-oauth/config.d.ts +69 -0
- package/dist/agent/oe-oauth/config.d.ts.map +1 -0
- package/dist/agent/oe-oauth/config.js +182 -0
- package/dist/agent/oe-oauth/config.js.map +1 -0
- package/dist/agent/oe-oauth/errors.d.ts +63 -0
- package/dist/agent/oe-oauth/errors.d.ts.map +1 -0
- package/dist/agent/oe-oauth/errors.js +73 -0
- package/dist/agent/oe-oauth/errors.js.map +1 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts +124 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -0
- package/dist/agent/oe-oauth/oauth-client.js +262 -0
- package/dist/agent/oe-oauth/oauth-client.js.map +1 -0
- package/dist/agent/oe-oauth/pkce.d.ts +8 -0
- package/dist/agent/oe-oauth/pkce.d.ts.map +1 -0
- package/dist/agent/oe-oauth/pkce.js +18 -0
- package/dist/agent/oe-oauth/pkce.js.map +1 -0
- package/dist/agent/oe-oauth/register.d.ts +17 -0
- package/dist/agent/oe-oauth/register.d.ts.map +1 -0
- package/dist/agent/oe-oauth/register.js +24 -0
- package/dist/agent/oe-oauth/register.js.map +1 -0
- package/dist/agent/oe-oauth/token-vault.d.ts +107 -0
- package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -0
- package/dist/agent/oe-oauth/token-vault.js +498 -0
- package/dist/agent/oe-oauth/token-vault.js.map +1 -0
- package/dist/agent/oe-oauth/user-auth-flow.d.ts +10 -0
- package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -0
- package/dist/agent/oe-oauth/user-auth-flow.js +66 -0
- package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -0
- package/dist/agent/postgres/neon.d.ts +29 -0
- package/dist/agent/postgres/neon.d.ts.map +1 -0
- package/dist/agent/postgres/neon.js +76 -0
- package/dist/agent/postgres/neon.js.map +1 -0
- package/dist/agent/sandbox/backend-stop.d.ts +98 -0
- package/dist/agent/sandbox/backend-stop.d.ts.map +1 -0
- package/dist/agent/sandbox/backend-stop.js +188 -0
- package/dist/agent/sandbox/backend-stop.js.map +1 -0
- package/dist/agent/sandbox/created-sandbox-registry.d.ts +15 -0
- package/dist/agent/sandbox/created-sandbox-registry.d.ts.map +1 -0
- package/dist/agent/sandbox/created-sandbox-registry.js +26 -0
- package/dist/agent/sandbox/created-sandbox-registry.js.map +1 -0
- package/dist/agent/sandbox/extend-workspace-time.d.ts +31 -0
- package/dist/agent/sandbox/extend-workspace-time.d.ts.map +1 -0
- package/dist/agent/sandbox/extend-workspace-time.js +63 -0
- package/dist/agent/sandbox/extend-workspace-time.js.map +1 -0
- package/dist/agent/sandbox/vercel-lifecycle.d.ts +38 -0
- package/dist/agent/sandbox/vercel-lifecycle.d.ts.map +1 -0
- package/dist/agent/sandbox/vercel-lifecycle.js +244 -0
- package/dist/agent/sandbox/vercel-lifecycle.js.map +1 -0
- package/package.json +204 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The own-rolled OAuth USER-AUTH-FLOW adapter — core's vendor-neutral port over our own client and token
|
|
2
|
+
// vault. The callback completer shares ONE vault instance, so the single-flight lock covers both paths.
|
|
3
|
+
|
|
4
|
+
import type { TokenResult } from "eve/connections";
|
|
5
|
+
import type { UserAuthFlowProvider, UserAuthSubject, UserTokenMintParams, OeOauthCallbackCompleter, CredentialPurgeHook } from "@open-everywhere/core/agent/adapter-api.js";
|
|
6
|
+
import type { OeOauthProviderConfig } from "./config.js";
|
|
7
|
+
import { makeOeOauthTokenVault, type OeOauthVaultDeps, type OeOauthSubject } from "./token-vault.js";
|
|
8
|
+
import { isUserAuthRequiredError } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
/** Normalise the port's UserAuthSubject into the vault's subject shape. */
|
|
11
|
+
function toVaultSubject(subject: UserAuthSubject): OeOauthSubject {
|
|
12
|
+
return subject.type === "app" ? { type: "app" } : { type: "user", id: subject.id };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Build the own-rolled provider plus the callback completer, sharing one token vault. `deps` is the DI seam. */
|
|
16
|
+
export function makeOeOauth(
|
|
17
|
+
providers: ReadonlyMap<string, OeOauthProviderConfig>,
|
|
18
|
+
deps: OeOauthVaultDeps = {},
|
|
19
|
+
): { readonly flow: UserAuthFlowProvider; readonly completer: OeOauthCallbackCompleter; readonly purge: CredentialPurgeHook } {
|
|
20
|
+
const vault = makeOeOauthTokenVault(providers, deps);
|
|
21
|
+
|
|
22
|
+
const flow: UserAuthFlowProvider = {
|
|
23
|
+
async mintServiceToken(providerRef: string, _getTokenOptions: unknown): Promise<TokenResult> {
|
|
24
|
+
return { token: await vault.accessTokenFor(providerRef, { type: "app" }) };
|
|
25
|
+
},
|
|
26
|
+
async mintUserToken(providerRef: string, params: UserTokenMintParams): Promise<TokenResult> {
|
|
27
|
+
return { token: await vault.accessTokenFor(providerRef, { type: "user", id: params.subject.id }) };
|
|
28
|
+
},
|
|
29
|
+
async startAuthorization(providerRef, params, options): Promise<{ url: string }> {
|
|
30
|
+
// `consent` (OPE-1619) rides through UNREAD — it is core's opaque record of the request this
|
|
31
|
+
// detour interrupted, and the vault stores it beside the pending row and returns it at the
|
|
32
|
+
// callback. This adapter never inspects it.
|
|
33
|
+
return vault.startAuthorization(providerRef, toVaultSubject(params.subject), {
|
|
34
|
+
...(options.expiresInMs ? { expiresInMs: options.expiresInMs } : {}),
|
|
35
|
+
...(options.consent ? { consent: options.consent } : {}),
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
isUserAuthRequired(e: unknown): boolean {
|
|
39
|
+
return isUserAuthRequiredError(e);
|
|
40
|
+
},
|
|
41
|
+
async environmentClaim(): Promise<string> {
|
|
42
|
+
return "self";
|
|
43
|
+
},
|
|
44
|
+
async hasFreshGrant(providerRef: string, subject: UserAuthSubject): Promise<boolean> {
|
|
45
|
+
// (OPE-93) Existence check of the user's token vault row — the mid-turn self-heal signal (the real
|
|
46
|
+
// mint refreshes an expired token, so ROW EXISTENCE is what matters). Fail-soft: a vault-read
|
|
47
|
+
// failure reports `false`, never throws, so a probe error leaves the memo's short-circuit standing.
|
|
48
|
+
try {
|
|
49
|
+
return await vault.hasToken(providerRef, toVaultSubject(subject));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const completer: OeOauthCallbackCompleter = async ({ state, code }) => {
|
|
57
|
+
try {
|
|
58
|
+
// `subject` and `rebound` ride out UNREAD by this adapter: core's callback route audits them as
|
|
59
|
+
// `connector.credential_bound` (OPE-1937). No token material is on this path, by construction.
|
|
60
|
+
const { providerRef, subject, rebound, consent } = await vault.completeAuthorization(state, code);
|
|
61
|
+
return { ok: true, connectionName: providerRef, subject, rebound, ...(consent ? { consent } : {}) };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// The credential-purge hook, sharing the mint methods' vault: whatever this adapter can MINT for a subject it
|
|
68
|
+
// can also DESTROY. There is no app-subject purge — the service identity is not a person.
|
|
69
|
+
const purge: CredentialPurgeHook = {
|
|
70
|
+
async purgeSubject(subjectId: string): Promise<number> {
|
|
71
|
+
return vault.forgetAll({ type: "user", id: subjectId });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return { flow, completer, purge };
|
|
76
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Neon (serverless Postgres) adapter — where the driver is constructed and handed to core's register seams,
|
|
2
|
+
// so core stays vendor-SDK-free. LIVE-ONLY: it needs a real DATABASE_URL, and the driver imports dynamically.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
registerPostgres,
|
|
6
|
+
registerOperationalPostgres,
|
|
7
|
+
initPostgresSchema,
|
|
8
|
+
type PgClient,
|
|
9
|
+
initOperationalSchema,
|
|
10
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Construct a Neon serverless client, initialise the schema, and register it as both the memory and the
|
|
14
|
+
* operational Postgres backend. Call once at startup, before the first session.
|
|
15
|
+
*/
|
|
16
|
+
export async function registerNeon(connectionString = process.env.DATABASE_URL): Promise<PgClient> {
|
|
17
|
+
if (!connectionString) {
|
|
18
|
+
throw new Error("registerNeon: no connection string (set DATABASE_URL)");
|
|
19
|
+
}
|
|
20
|
+
// 🔴 This specifier is DELIBERATELY statically analyzable: hosted functions ship no node_modules, so the
|
|
21
|
+
// driver must be inlined by static resolution. A dynamic form killed every prod request (2026-08-11).
|
|
22
|
+
const mod = (await import("@neondatabase/serverless")) as {
|
|
23
|
+
Pool: new (cfg: { connectionString: string }) => unknown;
|
|
24
|
+
neonConfig: NeonConfig;
|
|
25
|
+
};
|
|
26
|
+
// Local dev only: the serverless driver speaks WebSockets/HTTPS, not raw TCP, so a plain Postgres
|
|
27
|
+
// (the docker-compose postgres:16 sidecar) is reached through a `wsproxy` in front of it. OFF unless
|
|
28
|
+
// OE_NEON_WS_PROXY is set ⇒ the hosted Neon path is byte-identical. See docker-compose.yml.
|
|
29
|
+
configureLocalWsProxy(mod.neonConfig);
|
|
30
|
+
const { Pool } = mod;
|
|
31
|
+
const pool = new Pool({ connectionString }) as {
|
|
32
|
+
query(text: string, params?: readonly unknown[]): Promise<{ rows: unknown[] }>;
|
|
33
|
+
connect(): Promise<PooledConnection>;
|
|
34
|
+
on?(event: "error", listener: (err: unknown) => void): unknown;
|
|
35
|
+
};
|
|
36
|
+
attachPoolErrorHandler(pool);
|
|
37
|
+
const client: PgClient = {
|
|
38
|
+
query: (text, params) => pool.query(text, params),
|
|
39
|
+
// The connection-pinning seam (OPE-1636). The audit chain's append needs `BEGIN … COMMIT`
|
|
40
|
+
// plus a transaction-scoped advisory lock on ONE connection; without this the hosted
|
|
41
|
+
// runtime's separate functions fork the chain. Every exit path releases, so a throwing
|
|
42
|
+
// callback can never leak a connection out of the pool.
|
|
43
|
+
withConnection: async (fn) => {
|
|
44
|
+
const conn = await pool.connect();
|
|
45
|
+
try {
|
|
46
|
+
const result = await fn({ query: (text, params) => conn.query(text, params) });
|
|
47
|
+
conn.release();
|
|
48
|
+
return result;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// 🔴 `release(err)` DESTROYS the connection; a bare `release()` returns it to the pool
|
|
51
|
+
// whatever state it is in. A failed append can leave an open transaction holding the
|
|
52
|
+
// org's advisory lock, and that connection would then block every other appender for
|
|
53
|
+
// that org until it happened to be reused.
|
|
54
|
+
conn.release(err);
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
await initPostgresSchema(client);
|
|
61
|
+
await initOperationalSchema(client);
|
|
62
|
+
registerPostgres(client);
|
|
63
|
+
registerOperationalPostgres(client);
|
|
64
|
+
return client;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One connection checked out of the pool: queries like the pool itself, plus the `release()`
|
|
68
|
+
* that hands it back. */
|
|
69
|
+
interface PooledConnection {
|
|
70
|
+
query(text: string, params?: readonly unknown[]): Promise<{ rows: unknown[] }>;
|
|
71
|
+
release(err?: unknown): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The subset of @neondatabase/serverless's `neonConfig` we touch for the local-wsproxy recipe.
|
|
75
|
+
* `wsProxy` maps a target (host, port) to the proxy's `<host:port>/v1?address=…` URL. */
|
|
76
|
+
interface NeonConfig {
|
|
77
|
+
wsProxy?: (host: string, port: number) => string;
|
|
78
|
+
useSecureWebSocket?: boolean;
|
|
79
|
+
pipelineTLS?: boolean;
|
|
80
|
+
pipelineConnect?: boolean | "password";
|
|
81
|
+
webSocketConstructor?: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Point the driver at a local `wsproxy` so it reaches a plain Postgres over the compose network. NO-OP unless
|
|
86
|
+
* OE_NEON_WS_PROXY is set. Insecure WS is correct for a same-network local proxy ONLY, never a remote one.
|
|
87
|
+
*/
|
|
88
|
+
export function configureLocalWsProxy(neonConfig: NeonConfig): void {
|
|
89
|
+
const proxy = process.env.OE_NEON_WS_PROXY?.trim();
|
|
90
|
+
if (!proxy) return;
|
|
91
|
+
neonConfig.wsProxy = (host, port) => `${proxy}/v1?address=${host}:${port}`;
|
|
92
|
+
neonConfig.useSecureWebSocket = false;
|
|
93
|
+
neonConfig.pipelineTLS = false;
|
|
94
|
+
neonConfig.pipelineConnect = false;
|
|
95
|
+
if (!neonConfig.webSocketConstructor && typeof (globalThis as { WebSocket?: unknown }).WebSocket !== "undefined") {
|
|
96
|
+
neonConfig.webSocketConstructor = (globalThis as { WebSocket: unknown }).WebSocket;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Pool is a long-lived EventEmitter, and an idle connection's socket can error during teardown. With no
|
|
102
|
+
* 'error' listener Node kills the process, even after the request returned 200 — this listener is the fix.
|
|
103
|
+
*/
|
|
104
|
+
export function attachPoolErrorHandler(pool: { on?(event: "error", listener: (err: unknown) => void): unknown }): void {
|
|
105
|
+
pool.on?.("error", (err) => {
|
|
106
|
+
console.warn(`[open-everywhere:neon] pool error on an idle connection (ignored, no query affected): ${err instanceof Error ? err.message : String(err)}`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// WHO OWNS THE TURN-END SANDBOX STOP — one implementation per sandbox backend. Each is a ZERO-SIDE-EFFECT
|
|
2
|
+
// peek first: eve's own `stop()` provisions, and a turn-end hook must never create a sandbox to stop one.
|
|
3
|
+
|
|
4
|
+
import { Sandbox } from "@vercel/sandbox";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { onVercel, resolveSelfHostBackendKind } from "@open-everywhere/core/agent/adapter-api.js";
|
|
7
|
+
import { getRecordedSandboxName } from "./created-sandbox-registry.js";
|
|
8
|
+
|
|
9
|
+
/** One sandbox as its backend reports it — `createdAt` (epoch ms) is the one honestly-observable
|
|
10
|
+
* timing reference (see vercel-lifecycle.ts's file-level comment on delta billing). */
|
|
11
|
+
export interface SandboxListing {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly status: string;
|
|
14
|
+
readonly createdAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The backend-specific half of the turn-end stop. One implementation per sandbox backend OE can
|
|
19
|
+
* select; `resolveSandboxStopper()` picks the one matching the running deployment.
|
|
20
|
+
*
|
|
21
|
+
* Injected into `stopSessionSandboxes` so the decision logic is testable without credentials, a
|
|
22
|
+
* docker daemon, or a platform.
|
|
23
|
+
*/
|
|
24
|
+
export interface SandboxStopper {
|
|
25
|
+
/** Backend id, matching `OE_SANDBOX_BACKEND` / eve's backend name. For logs and tests. */
|
|
26
|
+
readonly backend: string;
|
|
27
|
+
/** Does this backend bill sandbox runtime by wall clock? Only then is a stop priced. */
|
|
28
|
+
readonly metered: boolean;
|
|
29
|
+
/** One line naming who owns the stop on this deployment, logged once per process. */
|
|
30
|
+
readonly ownership: string;
|
|
31
|
+
/** Sandboxes already tagged to this session. MUST NOT provision — an empty list is the
|
|
32
|
+
* correct answer for a backend that cannot look without creating. */
|
|
33
|
+
list(sessionId: string): Promise<SandboxListing[]>;
|
|
34
|
+
/** Stop the compute, preserving whatever the backend needs to reopen the session. */
|
|
35
|
+
stop(name: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Re-tag an existing sandbox onto `sessionId`. Returns `true` when the backend re-tagged, and
|
|
38
|
+
* `false` when this backend never re-tags on attach — in which case there is nothing to undo
|
|
39
|
+
* and the caller must not treat the `false` as a failure. See the OPE-447 note on the Vercel
|
|
40
|
+
* implementation for why this exists at all.
|
|
41
|
+
*/
|
|
42
|
+
retag(name: string, sessionId: string): Promise<boolean>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Only a booted (or booting) sandbox is worth stopping; stopped/snapshotting are already done.
|
|
46
|
+
* Vercel's status vocabulary is the shared one — every other stopper normalises onto it. */
|
|
47
|
+
export function shouldStop(status: string): boolean {
|
|
48
|
+
return status === "running" || status === "pending";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Vercel ────────────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `Sandbox.list({tags})` is the zero-side-effect peek this hook depends on, and `resume: false` keeps a
|
|
55
|
+
* stopped sandbox stopped. RETAG: `update` REPLACES the tag map, so the restore merges and overrides one key.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The raw Vercel SDK surface `createVercelList` needs — injectable so the OPE-722 augmentation
|
|
60
|
+
* below (falling back to a direct `Sandbox.get` for a name THIS process recorded opening) is
|
|
61
|
+
* unit-testable without live credentials. `defaultVercelListSdk` is the real `@vercel/sandbox`
|
|
62
|
+
* calls; tests inject a fake to prove the merge deterministically.
|
|
63
|
+
*/
|
|
64
|
+
export interface VercelListSdk {
|
|
65
|
+
/** The tag-index query. OPE-722 reported this returning empty immediately after
|
|
66
|
+
* `Sandbox.create`, attributed to eventual consistency in the tag index; see the OPE-722
|
|
67
|
+
* doc comment on `createVercelList` below for what a later re-probe actually found. */
|
|
68
|
+
listByTag(sessionId: string): Promise<SandboxListing[]>;
|
|
69
|
+
/** A primary-key lookup by name. `undefined` when the sandbox genuinely does not exist (or the
|
|
70
|
+
* lookup failed) — the caller treats that exactly like "not found by the tag index either". */
|
|
71
|
+
getByName(name: string): Promise<SandboxListing | undefined>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const defaultVercelListSdk: VercelListSdk = {
|
|
75
|
+
async listByTag(sessionId) {
|
|
76
|
+
const result = await Sandbox.list({ tags: { sessionId } });
|
|
77
|
+
const out: SandboxListing[] = [];
|
|
78
|
+
for await (const s of result) out.push({ name: s.name, status: s.status, createdAt: s.createdAt });
|
|
79
|
+
return out;
|
|
80
|
+
},
|
|
81
|
+
async getByName(name) {
|
|
82
|
+
try {
|
|
83
|
+
const s = await Sandbox.get({ name, resume: false });
|
|
84
|
+
// Sandbox.get()'s `.createdAt` is a Date; Sandbox.list()'s iterated items report a number
|
|
85
|
+
// (epoch ms) for the same field — normalise onto the number SandboxListing already uses.
|
|
86
|
+
return { name: s.name, status: s.status, createdAt: s.createdAt.getTime() };
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Augment the tag-index result with a direct `Sandbox.get` for any name THIS process recorded this turn
|
|
95
|
+
* (created-sandbox-registry.ts) — a primary-key read, so the stop never depends on tag-index freshness.
|
|
96
|
+
*/
|
|
97
|
+
export function createVercelList(sdk: VercelListSdk = defaultVercelListSdk): SandboxStopper["list"] {
|
|
98
|
+
return async (sessionId) => {
|
|
99
|
+
const out = await sdk.listByTag(sessionId);
|
|
100
|
+
const recordedName = getRecordedSandboxName(sessionId);
|
|
101
|
+
if (recordedName !== undefined && !out.some((s) => s.name === recordedName)) {
|
|
102
|
+
const direct = await sdk.getByName(recordedName);
|
|
103
|
+
if (direct !== undefined) out.push(direct);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const vercelStopper: SandboxStopper = {
|
|
110
|
+
backend: "vercel",
|
|
111
|
+
metered: true,
|
|
112
|
+
ownership:
|
|
113
|
+
"stop owner = vercel — @vercel/sandbox stops the session VM at turn end, and its runtime seconds are priced into the spend ledger",
|
|
114
|
+
list: createVercelList(),
|
|
115
|
+
async stop(name) {
|
|
116
|
+
await (await Sandbox.get({ name, resume: false })).stop();
|
|
117
|
+
},
|
|
118
|
+
async retag(name, sessionId) {
|
|
119
|
+
const sandbox = await Sandbox.get({ name, resume: false });
|
|
120
|
+
await sandbox.update({ tags: { ...(sandbox.tags ?? {}), sessionId } });
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ── docker ────────────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* eve labels every session container it CREATES with its diagnostic tags, prefixed
|
|
129
|
+
* `eve.sandbox.tag.` (its docker binding's `startDockerContainer`). That label is the only
|
|
130
|
+
* zero-side-effect handle on a running session container, so `docker ps` by label is the peek.
|
|
131
|
+
*
|
|
132
|
+
* The container is long-lived by construction: eve starts it with a `sleep 2147483647` keepalive
|
|
133
|
+
* and only ever stops it on `handle.stop()` or server shutdown. Without a turn-end stop a
|
|
134
|
+
* self-hosted box therefore accumulates one running container per conversation, indefinitely.
|
|
135
|
+
* `docker stop` (never `rm`) is the right verb: eve's `create` restarts a stopped container by
|
|
136
|
+
* name and the filesystem survives, which is the same stop/resume shape Vercel snapshots give.
|
|
137
|
+
*/
|
|
138
|
+
export const DOCKER_SESSION_LABEL = "eve.sandbox.tag.sessionId";
|
|
139
|
+
|
|
140
|
+
/** The docker CLI as this module needs it. Injected in tests; the default shells out exactly as
|
|
141
|
+
* eve does, honouring `EVE_DOCKER_PATH` so a Podman-shimmed deployment keeps working. */
|
|
142
|
+
export interface DockerRunner {
|
|
143
|
+
(args: readonly string[]): Promise<string>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const defaultDockerRunner: DockerRunner = async (args) =>
|
|
147
|
+
await new Promise<string>((resolve, reject) => {
|
|
148
|
+
const bin = process.env.EVE_DOCKER_PATH?.trim() || "docker";
|
|
149
|
+
execFile(bin, [...args], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
|
|
150
|
+
if (err) reject(err);
|
|
151
|
+
else resolve(stdout);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** Docker's own state vocabulary, mapped onto the shared one `shouldStop` reads. Anything else
|
|
156
|
+
* (exited, dead, removing, paused) passes through unchanged and is left alone. */
|
|
157
|
+
export function dockerStatusToSandboxStatus(state: string): string {
|
|
158
|
+
if (state === "running") return "running";
|
|
159
|
+
if (state === "created" || state === "restarting") return "pending";
|
|
160
|
+
return state;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `docker inspect` reports `0001-01-01T00:00:00Z` for a container that never started. Anything
|
|
164
|
+
* before this floor is not a real start time — report "now" so the window is 0, never a
|
|
165
|
+
* two-thousand-year one. */
|
|
166
|
+
const DOCKER_START_TIME_FLOOR_MS = Date.UTC(2000, 0, 1);
|
|
167
|
+
|
|
168
|
+
export function createDockerStopper(run: DockerRunner = defaultDockerRunner): SandboxStopper {
|
|
169
|
+
return {
|
|
170
|
+
backend: "docker",
|
|
171
|
+
metered: false,
|
|
172
|
+
ownership:
|
|
173
|
+
"stop owner = docker — eve's session containers are stopped through the docker CLI at turn end. Runtime is NOT metered per second on this backend, so no spend-ledger row is written for it",
|
|
174
|
+
async list(sessionId) {
|
|
175
|
+
const names = (await run(["ps", "-a", "--no-trunc", "--filter", `label=${DOCKER_SESSION_LABEL}=${sessionId}`, "--format", "{{.Names}}"]))
|
|
176
|
+
.split("\n")
|
|
177
|
+
.map((l) => l.trim())
|
|
178
|
+
.filter((l) => l.length > 0);
|
|
179
|
+
if (names.length === 0) return [];
|
|
180
|
+
// One inspect for all of them; `{{.Name}}` carries a leading slash, so the name is read back
|
|
181
|
+
// rather than zipped by position. `docker inspect` FAILS THE WHOLE CALL if any one name has
|
|
182
|
+
// gone away since the peek, so a vanished container must not blind the sweep to its
|
|
183
|
+
// siblings: fall back to treating every listed name as running now. `docker stop` on a name
|
|
184
|
+
// that no longer exists then fails soft, per sandbox, in stopSessionSandboxes.
|
|
185
|
+
let inspected: string;
|
|
186
|
+
try {
|
|
187
|
+
inspected = await run(["inspect", "--format", "{{.Name}}\t{{.State.Status}}\t{{.State.StartedAt}}", ...names]);
|
|
188
|
+
} catch {
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
return names.map((name) => ({ name, status: "running", createdAt: now }));
|
|
191
|
+
}
|
|
192
|
+
const out: SandboxListing[] = [];
|
|
193
|
+
for (const line of inspected.split("\n")) {
|
|
194
|
+
const [rawName, state, startedAt] = line.trim().split("\t");
|
|
195
|
+
if (rawName === undefined || state === undefined) continue;
|
|
196
|
+
const name = rawName.replace(/^\//, "");
|
|
197
|
+
if (name.length === 0) continue;
|
|
198
|
+
const parsed = startedAt === undefined ? Number.NaN : Date.parse(startedAt);
|
|
199
|
+
const createdAt = Number.isFinite(parsed) && parsed >= DOCKER_START_TIME_FLOOR_MS ? parsed : Date.now();
|
|
200
|
+
out.push({ name, status: dockerStatusToSandboxStatus(state), createdAt });
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
},
|
|
204
|
+
async stop(name) {
|
|
205
|
+
await run(["stop", "-t", "0", name]);
|
|
206
|
+
},
|
|
207
|
+
async retag() {
|
|
208
|
+
// eve's docker binding applies labels ONLY on `docker run`, i.e. when it creates the
|
|
209
|
+
// container. A reattach (`container inspect` then `docker start`) never re-labels, and a
|
|
210
|
+
// running container's labels are immutable anyway. So the OPE-447 re-tag hazard does not
|
|
211
|
+
// exist on this backend and there is nothing to hand back.
|
|
212
|
+
return false;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── just-bash ─────────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The just-bash backend is an in-process interpreter — no VM, no container, no metered runtime — so this
|
|
221
|
+
* stopper is deliberately inert. It names itself in the log rather than swallowing an error.
|
|
222
|
+
*/
|
|
223
|
+
export const justBashStopper: SandboxStopper = {
|
|
224
|
+
backend: "just-bash",
|
|
225
|
+
metered: false,
|
|
226
|
+
ownership:
|
|
227
|
+
"stop owner = NONE — the just-bash backend runs inside this app process. There is no VM or container to stop and no metered runtime, so the turn-end sweep is inert by design. Set OE_SANDBOX_BACKEND=docker for a backend whose compute is stopped at turn end",
|
|
228
|
+
async list() {
|
|
229
|
+
return [];
|
|
230
|
+
},
|
|
231
|
+
async stop() {
|
|
232
|
+
// Unreachable: list() never yields a name.
|
|
233
|
+
},
|
|
234
|
+
async retag() {
|
|
235
|
+
return false;
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// ── selection ─────────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The ONE "who stops the sandbox on this deployment" decision. It reads the SAME two switches the
|
|
243
|
+
* app reads to BUILD the backend — `onVercel()` and `resolveSelfHostBackendKind()` — so the
|
|
244
|
+
* backend that runs and the backend that stops can never disagree. An unknown
|
|
245
|
+
* `OE_SANDBOX_BACKEND` throws here exactly as it throws at boot; the hook's fail-soft catch logs
|
|
246
|
+
* it rather than failing the turn.
|
|
247
|
+
*/
|
|
248
|
+
export function resolveSandboxStopper(): SandboxStopper {
|
|
249
|
+
if (forcedStopper !== undefined) return forcedStopper;
|
|
250
|
+
if (onVercel()) return vercelStopper;
|
|
251
|
+
return resolveSelfHostBackendKind() === "justbash" ? justBashStopper : createDockerStopper();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Test seam: force the resolved stopper so the hook's own branching can be driven without a
|
|
255
|
+
* platform, credentials or a docker daemon. Pass `undefined` to restore real selection. */
|
|
256
|
+
let forcedStopper: SandboxStopper | undefined;
|
|
257
|
+
export function setSandboxStopperForTest(stopper: SandboxStopper | undefined): void {
|
|
258
|
+
forcedStopper = stopper;
|
|
259
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Record the sandbox name THIS process opened, so the stop hook can reach it with a primary-key
|
|
2
|
+
// `Sandbox.get` instead of the tag index. It NARROWS the create-then-stop race, and never replaces `list()`.
|
|
3
|
+
const recordedSandboxNames = new Map<string, string>();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Record the vendor sandbox name eve just opened for `sessionId`, in THIS process. Called from
|
|
7
|
+
* `agent/sandbox/sandbox.ts`'s `onSession` hook. Never throws — a caller that races this against
|
|
8
|
+
* a torn-down process loses nothing beyond falling back to the pre-fix `Sandbox.list` behaviour.
|
|
9
|
+
*/
|
|
10
|
+
export function recordCreatedSandbox(sessionId: string, name: string): void {
|
|
11
|
+
if (sessionId.length === 0 || name.length === 0) return;
|
|
12
|
+
recordedSandboxNames.set(sessionId, name);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The vendor sandbox name recorded for `sessionId` in THIS process, if any. `undefined` means
|
|
17
|
+
* either no sandbox was opened here this invocation, or it was opened by a different process —
|
|
18
|
+
* both cases fall back to the vendor's tag index, exactly as before this fix.
|
|
19
|
+
*/
|
|
20
|
+
export function getRecordedSandboxName(sessionId: string): string | undefined {
|
|
21
|
+
return recordedSandboxNames.get(sessionId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Test seam: reset between assertions so one test's recording can't leak into the next. */
|
|
25
|
+
export function clearRecordedSandboxesForTest(): void {
|
|
26
|
+
recordedSandboxNames.clear();
|
|
27
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// `extend_workspace_time` — lengthen the sandbox session's FIXED fuse before a long command. It extends
|
|
2
|
+
// BLIND (no remaining-time reading exists), is capped and audited, and plan ceilings still apply upstream.
|
|
3
|
+
|
|
4
|
+
import { defineTool } from "eve/tools";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { Sandbox } from "@vercel/sandbox";
|
|
7
|
+
import { auditToolAction, getSettings } from "@open-everywhere/core/agent/adapter-api.js";
|
|
8
|
+
import { shouldStop } from "./vercel-lifecycle.js";
|
|
9
|
+
import { vercelStopper } from "./backend-stop.js";
|
|
10
|
+
|
|
11
|
+
/** Per-call cap on the extension, in minutes — the `sandbox.extendMaxMinutes` setting
|
|
12
|
+
* (OPE-371), with `sandbox.extendMaxMinutes` as its declared ops override. */
|
|
13
|
+
export function maxExtendMinutes(): number {
|
|
14
|
+
return getSettings().sandbox.extendMaxMinutes;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Injected SDK surface, so the logic is testable without Vercel credentials. */
|
|
18
|
+
export interface ExtendDeps {
|
|
19
|
+
list(sessionId: string): Promise<Array<{ name: string; status: string }>>;
|
|
20
|
+
extend(name: string, ms: number): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The list is `vercelStopper.list` — the same tag-index-plus-id-record lookup the stop hook uses — rather
|
|
24
|
+
// than a second, independent `Sandbox.list({tags})` call.
|
|
25
|
+
const sdkDeps: ExtendDeps = {
|
|
26
|
+
list: vercelStopper.list,
|
|
27
|
+
async extend(name, ms) {
|
|
28
|
+
await (await Sandbox.get({ name })).extendTimeout(ms);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extend every RUNNING sandbox tagged with this session by `minutes` (clamped to the
|
|
34
|
+
* cap). A stopped/absent sandbox needs no extension — its next boot gets a full fresh
|
|
35
|
+
* window — so that case reports rather than errors.
|
|
36
|
+
*/
|
|
37
|
+
export async function extendSessionSandboxes(
|
|
38
|
+
sessionId: string,
|
|
39
|
+
minutes: number,
|
|
40
|
+
deps: ExtendDeps = sdkDeps,
|
|
41
|
+
): Promise<{ extended: string[]; clampedTo: number }> {
|
|
42
|
+
const clampedTo = Math.min(Math.max(1, Math.floor(minutes)), maxExtendMinutes());
|
|
43
|
+
const extended: string[] = [];
|
|
44
|
+
for (const s of await deps.list(sessionId)) {
|
|
45
|
+
if (!shouldStop(s.status)) continue; // same "is the VM up" predicate as the stop hook
|
|
46
|
+
await deps.extend(s.name, clampedTo * 60_000);
|
|
47
|
+
extended.push(s.name);
|
|
48
|
+
}
|
|
49
|
+
return { extended, clampedTo };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default defineTool({
|
|
53
|
+
description:
|
|
54
|
+
"Extend your workspace VM's lifetime by N minutes. Call this BEFORE starting a long-running command " +
|
|
55
|
+
"(a big install, build, or a test run you expect to take more than ~10 minutes): the workspace has a " +
|
|
56
|
+
"fixed time window and a command still running when it expires is killed. There is no visible countdown, " +
|
|
57
|
+
"so extend by roughly the time the upcoming work needs. If no VM is currently running, nothing needs " +
|
|
58
|
+
"extending — your next command boots a fresh one with a full window.",
|
|
59
|
+
inputSchema: z.object({
|
|
60
|
+
minutes: z.number().int().min(1).describe("How many extra minutes the upcoming work needs."),
|
|
61
|
+
reason: z.string().optional().describe("What the time is for (goes to the audit log)."),
|
|
62
|
+
}),
|
|
63
|
+
async execute(input, ctx) {
|
|
64
|
+
const { extended, clampedTo } = await extendSessionSandboxes(ctx.session.id, input.minutes);
|
|
65
|
+
await auditToolAction(ctx, "sandbox.extend", {
|
|
66
|
+
minutes: clampedTo,
|
|
67
|
+
requested: input.minutes,
|
|
68
|
+
extended,
|
|
69
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
70
|
+
});
|
|
71
|
+
if (extended.length === 0) {
|
|
72
|
+
return "No workspace VM is currently running — nothing to extend. Your next command boots a fresh session with a full time window, so just proceed.";
|
|
73
|
+
}
|
|
74
|
+
const clampNote = clampedTo !== input.minutes ? ` (requested ${input.minutes}, capped at ${clampedTo} by sandbox.extendMaxMinutes)` : "";
|
|
75
|
+
return `Extended the workspace lifetime by ${clampedTo} minutes${clampNote}. Start the long-running work now.`;
|
|
76
|
+
},
|
|
77
|
+
});
|