@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.
Files changed (156) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +43 -0
  4. package/agent/aws/cli.ts +121 -0
  5. package/agent/aws/config.ts +71 -0
  6. package/agent/aws/register.ts +65 -0
  7. package/agent/aws/sso.ts +567 -0
  8. package/agent/aws/sts.ts +191 -0
  9. package/agent/blob/hosting.ts +24 -0
  10. package/agent/browser/config.ts +62 -0
  11. package/agent/browser/register.ts +66 -0
  12. package/agent/browser/sandbox.ts +39 -0
  13. package/agent/contributions.ts +12 -0
  14. package/agent/directory/config.ts +138 -0
  15. package/agent/directory/okta.ts +226 -0
  16. package/agent/directory/register.ts +16 -0
  17. package/agent/instrumentation.ts +42 -0
  18. package/agent/model/approval-judge.ts +57 -0
  19. package/agent/model/dream-judge.ts +105 -0
  20. package/agent/model/gateway-vision.ts +162 -0
  21. package/agent/model/gateway.ts +85 -0
  22. package/agent/oe-oauth/config.ts +246 -0
  23. package/agent/oe-oauth/errors.ts +77 -0
  24. package/agent/oe-oauth/oauth-client.ts +376 -0
  25. package/agent/oe-oauth/pkce.ts +21 -0
  26. package/agent/oe-oauth/register.ts +32 -0
  27. package/agent/oe-oauth/token-vault.ts +652 -0
  28. package/agent/oe-oauth/user-auth-flow.ts +76 -0
  29. package/agent/postgres/neon.ts +108 -0
  30. package/agent/sandbox/backend-stop.ts +259 -0
  31. package/agent/sandbox/created-sandbox-registry.ts +27 -0
  32. package/agent/sandbox/extend-workspace-time.ts +77 -0
  33. package/agent/sandbox/vercel-lifecycle.ts +252 -0
  34. package/dist/agent/aws/cli.d.ts +24 -0
  35. package/dist/agent/aws/cli.d.ts.map +1 -0
  36. package/dist/agent/aws/cli.js +89 -0
  37. package/dist/agent/aws/cli.js.map +1 -0
  38. package/dist/agent/aws/config.d.ts +27 -0
  39. package/dist/agent/aws/config.d.ts.map +1 -0
  40. package/dist/agent/aws/config.js +46 -0
  41. package/dist/agent/aws/config.js.map +1 -0
  42. package/dist/agent/aws/register.d.ts +23 -0
  43. package/dist/agent/aws/register.d.ts.map +1 -0
  44. package/dist/agent/aws/register.js +39 -0
  45. package/dist/agent/aws/register.js.map +1 -0
  46. package/dist/agent/aws/sso.d.ts +109 -0
  47. package/dist/agent/aws/sso.d.ts.map +1 -0
  48. package/dist/agent/aws/sso.js +421 -0
  49. package/dist/agent/aws/sso.js.map +1 -0
  50. package/dist/agent/aws/sts.d.ts +27 -0
  51. package/dist/agent/aws/sts.d.ts.map +1 -0
  52. package/dist/agent/aws/sts.js +135 -0
  53. package/dist/agent/aws/sts.js.map +1 -0
  54. package/dist/agent/blob/hosting.d.ts +4 -0
  55. package/dist/agent/blob/hosting.d.ts.map +1 -0
  56. package/dist/agent/blob/hosting.js +20 -0
  57. package/dist/agent/blob/hosting.js.map +1 -0
  58. package/dist/agent/browser/config.d.ts +24 -0
  59. package/dist/agent/browser/config.d.ts.map +1 -0
  60. package/dist/agent/browser/config.js +50 -0
  61. package/dist/agent/browser/config.js.map +1 -0
  62. package/dist/agent/browser/register.d.ts +7 -0
  63. package/dist/agent/browser/register.d.ts.map +1 -0
  64. package/dist/agent/browser/register.js +47 -0
  65. package/dist/agent/browser/register.js.map +1 -0
  66. package/dist/agent/browser/sandbox.d.ts +23 -0
  67. package/dist/agent/browser/sandbox.d.ts.map +1 -0
  68. package/dist/agent/browser/sandbox.js +27 -0
  69. package/dist/agent/browser/sandbox.js.map +1 -0
  70. package/dist/agent/contributions.d.ts +16 -0
  71. package/dist/agent/contributions.d.ts.map +1 -0
  72. package/dist/agent/contributions.js +11 -0
  73. package/dist/agent/contributions.js.map +1 -0
  74. package/dist/agent/directory/config.d.ts +39 -0
  75. package/dist/agent/directory/config.d.ts.map +1 -0
  76. package/dist/agent/directory/config.js +101 -0
  77. package/dist/agent/directory/config.js.map +1 -0
  78. package/dist/agent/directory/okta.d.ts +19 -0
  79. package/dist/agent/directory/okta.d.ts.map +1 -0
  80. package/dist/agent/directory/okta.js +178 -0
  81. package/dist/agent/directory/okta.js.map +1 -0
  82. package/dist/agent/directory/register.d.ts +6 -0
  83. package/dist/agent/directory/register.d.ts.map +1 -0
  84. package/dist/agent/directory/register.js +16 -0
  85. package/dist/agent/directory/register.js.map +1 -0
  86. package/dist/agent/instrumentation.d.ts +5 -0
  87. package/dist/agent/instrumentation.d.ts.map +1 -0
  88. package/dist/agent/instrumentation.js +39 -0
  89. package/dist/agent/instrumentation.js.map +1 -0
  90. package/dist/agent/model/approval-judge.d.ts +15 -0
  91. package/dist/agent/model/approval-judge.d.ts.map +1 -0
  92. package/dist/agent/model/approval-judge.js +46 -0
  93. package/dist/agent/model/approval-judge.js.map +1 -0
  94. package/dist/agent/model/dream-judge.d.ts +40 -0
  95. package/dist/agent/model/dream-judge.d.ts.map +1 -0
  96. package/dist/agent/model/dream-judge.js +80 -0
  97. package/dist/agent/model/dream-judge.js.map +1 -0
  98. package/dist/agent/model/gateway-vision.d.ts +53 -0
  99. package/dist/agent/model/gateway-vision.d.ts.map +1 -0
  100. package/dist/agent/model/gateway-vision.js +129 -0
  101. package/dist/agent/model/gateway-vision.js.map +1 -0
  102. package/dist/agent/model/gateway.d.ts +30 -0
  103. package/dist/agent/model/gateway.d.ts.map +1 -0
  104. package/dist/agent/model/gateway.js +55 -0
  105. package/dist/agent/model/gateway.js.map +1 -0
  106. package/dist/agent/oe-oauth/config.d.ts +69 -0
  107. package/dist/agent/oe-oauth/config.d.ts.map +1 -0
  108. package/dist/agent/oe-oauth/config.js +182 -0
  109. package/dist/agent/oe-oauth/config.js.map +1 -0
  110. package/dist/agent/oe-oauth/errors.d.ts +63 -0
  111. package/dist/agent/oe-oauth/errors.d.ts.map +1 -0
  112. package/dist/agent/oe-oauth/errors.js +73 -0
  113. package/dist/agent/oe-oauth/errors.js.map +1 -0
  114. package/dist/agent/oe-oauth/oauth-client.d.ts +124 -0
  115. package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -0
  116. package/dist/agent/oe-oauth/oauth-client.js +262 -0
  117. package/dist/agent/oe-oauth/oauth-client.js.map +1 -0
  118. package/dist/agent/oe-oauth/pkce.d.ts +8 -0
  119. package/dist/agent/oe-oauth/pkce.d.ts.map +1 -0
  120. package/dist/agent/oe-oauth/pkce.js +18 -0
  121. package/dist/agent/oe-oauth/pkce.js.map +1 -0
  122. package/dist/agent/oe-oauth/register.d.ts +17 -0
  123. package/dist/agent/oe-oauth/register.d.ts.map +1 -0
  124. package/dist/agent/oe-oauth/register.js +24 -0
  125. package/dist/agent/oe-oauth/register.js.map +1 -0
  126. package/dist/agent/oe-oauth/token-vault.d.ts +107 -0
  127. package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -0
  128. package/dist/agent/oe-oauth/token-vault.js +498 -0
  129. package/dist/agent/oe-oauth/token-vault.js.map +1 -0
  130. package/dist/agent/oe-oauth/user-auth-flow.d.ts +10 -0
  131. package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -0
  132. package/dist/agent/oe-oauth/user-auth-flow.js +66 -0
  133. package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -0
  134. package/dist/agent/postgres/neon.d.ts +29 -0
  135. package/dist/agent/postgres/neon.d.ts.map +1 -0
  136. package/dist/agent/postgres/neon.js +76 -0
  137. package/dist/agent/postgres/neon.js.map +1 -0
  138. package/dist/agent/sandbox/backend-stop.d.ts +98 -0
  139. package/dist/agent/sandbox/backend-stop.d.ts.map +1 -0
  140. package/dist/agent/sandbox/backend-stop.js +188 -0
  141. package/dist/agent/sandbox/backend-stop.js.map +1 -0
  142. package/dist/agent/sandbox/created-sandbox-registry.d.ts +15 -0
  143. package/dist/agent/sandbox/created-sandbox-registry.d.ts.map +1 -0
  144. package/dist/agent/sandbox/created-sandbox-registry.js +26 -0
  145. package/dist/agent/sandbox/created-sandbox-registry.js.map +1 -0
  146. package/dist/agent/sandbox/extend-workspace-time.d.ts +31 -0
  147. package/dist/agent/sandbox/extend-workspace-time.d.ts.map +1 -0
  148. package/dist/agent/sandbox/extend-workspace-time.js +63 -0
  149. package/dist/agent/sandbox/extend-workspace-time.js.map +1 -0
  150. package/dist/agent/sandbox/vercel-lifecycle.d.ts +38 -0
  151. package/dist/agent/sandbox/vercel-lifecycle.d.ts.map +1 -0
  152. package/dist/agent/sandbox/vercel-lifecycle.js +244 -0
  153. package/dist/agent/sandbox/vercel-lifecycle.js.map +1 -0
  154. package/package.json +204 -0
  155. package/tsconfig.build.json +22 -0
  156. 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
+ });