xapi-to 0.1.19 → 0.1.21
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 +249 -1
- package/dist/chunk-UEQCIJ7T.js +922 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1802 -726
- package/dist/openai-sandbox-client.d.ts +85 -0
- package/dist/openai-sandbox-client.js +285 -0
- package/examples/openai-agents-sandbox-local.ts +131 -0
- package/examples/sandbox-api-cli-openai.mjs +450 -0
- package/package.json +25 -3
- package/scripts/openai-sandbox-agent-e2e.ts +219 -0
- package/scripts/sandbox-playground-e2e.mjs +463 -0
- package/skills/xapi/SKILL.md +19 -7
- package/skills/xapi/guides/linkedin.md +55 -0
- package/skills/xapi/guides/provider.md +198 -0
- package/skills/xapi/guides/sandbox.md +520 -0
- package/skills/xapi/guides/serper.md +124 -0
- package/src/client.ts +715 -0
- package/src/config.ts +160 -0
- package/src/openai-sandbox-client.ts +349 -0
- package/src/sandbox-client.ts +309 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config management
|
|
3
|
+
* Only apiKey is user-configurable. Host is built-in.
|
|
4
|
+
* Reads from env var XAPI_KEY or ~/.xapi/config.json
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
|
8
|
+
import { err } from './format.ts';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
export const XAPI_ACTION_HOST = process.env.XAPI_ACTION_HOST || 'action.xapi.to'; // action service (capabilities + APIs)
|
|
13
|
+
export const XAPI_API_HOST = process.env.XAPI_API_HOST || 'api.xapi.to'; // auth + agent API
|
|
14
|
+
export const XAPI_SANDBOX_HOST = process.env.XAPI_SANDBOX_HOST || 'sandbox.xapi.to'; // sandbox control plane
|
|
15
|
+
|
|
16
|
+
/** Returns https:// for remote hosts, http:// for localhost/loopback */
|
|
17
|
+
export function scheme(host: string): string {
|
|
18
|
+
return isLoopbackHost(host) ? 'http' : 'https';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Host allowlist ─────────────────────────────────────────────────────────────
|
|
22
|
+
// The API key is attached (as the XAPI-Key header) to every request the client
|
|
23
|
+
// makes. To honor the documented guarantee that the key is only ever sent to
|
|
24
|
+
// xapi-controlled hosts, every outbound host is checked against this allowlist
|
|
25
|
+
// before the key leaves the machine.
|
|
26
|
+
const ALLOWED_HOST_EXACT = ['xapi.to', 'xapi.xyz'];
|
|
27
|
+
const ALLOWED_HOST_SUFFIXES = ['.xapi.to', '.xapi.xyz'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract the hostname exactly as fetch/WHATWG URL resolves it. Hand-rolled string
|
|
31
|
+
* parsing is unsafe here: WHATWG treats "\" as "/", so `evil.example\@action.xapi.to`
|
|
32
|
+
* has hostname `evil.example` even though a naive suffix check sees `.xapi.to`. Using
|
|
33
|
+
* the same parser fetch uses keeps the allowlist check consistent with the host the
|
|
34
|
+
* request actually contacts. Returns '' for anything unparseable (→ not allowed).
|
|
35
|
+
*/
|
|
36
|
+
function hostnameOf(hostOrUrl: string): string {
|
|
37
|
+
const raw = hostOrUrl.includes('://') ? hostOrUrl : `http://${hostOrUrl}`;
|
|
38
|
+
try {
|
|
39
|
+
return new URL(raw).hostname.toLowerCase();
|
|
40
|
+
} catch {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True only for a syntactically valid IPv4 address inside 127.0.0.0/8.
|
|
47
|
+
* A prefix test like /^127\./ is unsafe — it also matches domain names such as
|
|
48
|
+
* `127.attacker.com` or `127.0.0.1.nip.io`, which resolve to attacker-controlled
|
|
49
|
+
* IPs and would let the API key escape the allowlist.
|
|
50
|
+
*/
|
|
51
|
+
function isLoopbackIPv4(h: string): boolean {
|
|
52
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
|
|
53
|
+
if (!m) return false;
|
|
54
|
+
const octets = m.slice(1).map(Number);
|
|
55
|
+
return octets.every((o) => o <= 255) && octets[0] === 127;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True for a localhost / loopback hostname (already normalized via hostnameOf). */
|
|
59
|
+
function isLoopbackHostname(h: string): boolean {
|
|
60
|
+
return (
|
|
61
|
+
h === 'localhost' ||
|
|
62
|
+
h.endsWith('.localhost') ||
|
|
63
|
+
h === '::1' ||
|
|
64
|
+
h === '[::1]' ||
|
|
65
|
+
isLoopbackIPv4(h)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True for localhost / loopback hosts, which are always permitted (local dev). */
|
|
70
|
+
export function isLoopbackHost(hostOrUrl: string): boolean {
|
|
71
|
+
return isLoopbackHostname(hostnameOf(hostOrUrl));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True if the API key is allowed to be sent to this host. */
|
|
75
|
+
export function isAllowedHost(hostOrUrl: string): boolean {
|
|
76
|
+
const h = hostnameOf(hostOrUrl);
|
|
77
|
+
if (!h) return false;
|
|
78
|
+
if (isLoopbackHostname(h)) return true;
|
|
79
|
+
if (ALLOWED_HOST_EXACT.includes(h)) return true;
|
|
80
|
+
return ALLOWED_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Throw if the API key must not be sent to this host. */
|
|
84
|
+
export function assertAllowedHost(hostOrUrl: string): void {
|
|
85
|
+
if (!isAllowedHost(hostOrUrl)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`refusing to contact untrusted host "${hostnameOf(hostOrUrl) || hostOrUrl}": ` +
|
|
88
|
+
`the xapi API key may only be sent to *.xapi.to, *.xapi.xyz, or localhost`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface XapiConfig {
|
|
94
|
+
actionHost: string;
|
|
95
|
+
sandboxHost?: string;
|
|
96
|
+
apiKey?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ApiKeySource = 'XAPI_KEY' | 'XAPI_API_KEY' | 'file' | 'none';
|
|
100
|
+
|
|
101
|
+
const CONFIG_DIR = join(homedir(), '.xapi');
|
|
102
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
103
|
+
|
|
104
|
+
function loadFileConfig(): { apiKey?: string } {
|
|
105
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
108
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
109
|
+
return typeof parsed.apiKey === 'string' && parsed.apiKey.trim()
|
|
110
|
+
? { apiKey: parsed.apiKey }
|
|
111
|
+
: {};
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getApiKeySource(): ApiKeySource {
|
|
118
|
+
if (process.env.XAPI_KEY) return 'XAPI_KEY';
|
|
119
|
+
if (process.env.XAPI_API_KEY) return 'XAPI_API_KEY';
|
|
120
|
+
return loadFileConfig().apiKey ? 'file' : 'none';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getConfig(): XapiConfig {
|
|
124
|
+
const file = loadFileConfig();
|
|
125
|
+
return {
|
|
126
|
+
actionHost: XAPI_ACTION_HOST,
|
|
127
|
+
sandboxHost: XAPI_SANDBOX_HOST,
|
|
128
|
+
apiKey: process.env.XAPI_KEY || process.env.XAPI_API_KEY || file.apiKey,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function requireApiKey(cfg: XapiConfig): void {
|
|
133
|
+
if (!cfg.apiKey) {
|
|
134
|
+
err('API key not configured', 'Run "npx xapi-to register" to create an account, or "npx xapi-to config set apiKey=<key>" to set an existing key.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function saveConfig(updates: { apiKey?: string }): void {
|
|
139
|
+
const current = loadFileConfig();
|
|
140
|
+
const merged = { ...current, ...updates };
|
|
141
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
142
|
+
if (process.platform !== 'win32') chmodSync(CONFIG_DIR, 0o700);
|
|
143
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
144
|
+
// `mode` only applies when a file is created. Repair permissions as well when
|
|
145
|
+
// overwriting a config file created by an older CLI version.
|
|
146
|
+
if (process.platform !== 'win32') chmodSync(CONFIG_FILE, 0o600);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function showConfig(): Record<string, unknown> {
|
|
150
|
+
const cfg = getConfig();
|
|
151
|
+
return {
|
|
152
|
+
actionHost: cfg.actionHost,
|
|
153
|
+
sandboxHost: cfg.sandboxHost,
|
|
154
|
+
apiKey: cfg.apiKey ? `${cfg.apiKey.slice(0, 8)}...` : undefined,
|
|
155
|
+
source: {
|
|
156
|
+
apiKey: getApiKeySource(),
|
|
157
|
+
},
|
|
158
|
+
configFile: CONFIG_FILE,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/** OpenAI Sandbox Agents SDK client backed by the xAPI Sandbox Gateway. */
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
Manifest,
|
|
6
|
+
normalizeSandboxClientCreateArgs,
|
|
7
|
+
type ExecCommandArgs,
|
|
8
|
+
type SandboxClient as AgentsSandboxClient,
|
|
9
|
+
type SandboxClientCreateArgs,
|
|
10
|
+
type SandboxExecResult,
|
|
11
|
+
type SandboxSession,
|
|
12
|
+
type SandboxSessionState,
|
|
13
|
+
} from '@openai/agents/sandbox';
|
|
14
|
+
import { HttpError } from './client.ts';
|
|
15
|
+
import {
|
|
16
|
+
sandboxAudit,
|
|
17
|
+
sandboxCreate,
|
|
18
|
+
sandboxExec,
|
|
19
|
+
sandboxGet,
|
|
20
|
+
sandboxQuote,
|
|
21
|
+
sandboxStateAction,
|
|
22
|
+
sandboxWait,
|
|
23
|
+
type SandboxClientOptions,
|
|
24
|
+
} from './sandbox-client.ts';
|
|
25
|
+
|
|
26
|
+
export type XapiAgentsSandboxOptions = {
|
|
27
|
+
apiKey: string;
|
|
28
|
+
sandboxHost?: string;
|
|
29
|
+
provider?: string;
|
|
30
|
+
maxHourlyUsd?: number;
|
|
31
|
+
model?: string;
|
|
32
|
+
workspaceRoot?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type XapiAgentsSandboxState = SandboxSessionState & {
|
|
36
|
+
instanceId: string;
|
|
37
|
+
provider: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type XapiAgentsSandboxEvidence = {
|
|
41
|
+
instanceId?: string;
|
|
42
|
+
provider?: string;
|
|
43
|
+
execCount: number;
|
|
44
|
+
shellMarkerSeen: boolean;
|
|
45
|
+
finalState?: string;
|
|
46
|
+
totalCost?: string | number;
|
|
47
|
+
auditCounts?: Record<string, number>;
|
|
48
|
+
auditStatuses?: Record<string, string[]>;
|
|
49
|
+
auditVerified?: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type RunOptions = { provider?: string; maxHourlyUsd?: number };
|
|
53
|
+
|
|
54
|
+
function countItems(value: unknown): number {
|
|
55
|
+
if (Array.isArray(value)) return value.length;
|
|
56
|
+
const item = value as { items?: unknown[]; data?: unknown[] } | null;
|
|
57
|
+
return item?.items?.length ?? item?.data?.length ?? 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function items(value: unknown): Array<Record<string, any>> {
|
|
61
|
+
if (Array.isArray(value)) return value as Array<Record<string, any>>;
|
|
62
|
+
const page = value as { items?: Array<Record<string, any>>; data?: Array<Record<string, any>> } | null;
|
|
63
|
+
return page?.items ?? page?.data ?? [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function positivePrice(value: number, name: string): number {
|
|
67
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive finite number`);
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function shellQuote(value: string): string {
|
|
72
|
+
if (!value || value.length > 4_096 || value.includes('\0')) {
|
|
73
|
+
throw new Error('workspaceRoot must be a non-empty path no longer than 4096 characters');
|
|
74
|
+
}
|
|
75
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sleep(ms: number) {
|
|
79
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function defaultWorkspaceRoot(provider: string): string {
|
|
83
|
+
if (provider === 'daytona') return '/home/daytona/openai-xapi';
|
|
84
|
+
return '/tmp/openai-xapi';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class XapiAgentsSandboxSession implements SandboxSession<XapiAgentsSandboxState> {
|
|
88
|
+
readonly state: XapiAgentsSandboxState;
|
|
89
|
+
private closed = false;
|
|
90
|
+
private closePromise?: Promise<void>;
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
state: XapiAgentsSandboxState,
|
|
94
|
+
private readonly options: SandboxClientOptions,
|
|
95
|
+
private readonly owner: XapiAgentsSandboxClient,
|
|
96
|
+
) {
|
|
97
|
+
this.state = state;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async running() {
|
|
101
|
+
const detail = await sandboxGet(this.options, this.state.instanceId);
|
|
102
|
+
return detail.observedState === 'RUNNING';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async exec(args: ExecCommandArgs): Promise<SandboxExecResult> {
|
|
106
|
+
const before = Date.now();
|
|
107
|
+
const result = await sandboxExec(this.options, this.state.instanceId, {
|
|
108
|
+
command: args.cmd,
|
|
109
|
+
...(args.workdir ? { cwd: args.workdir } : {}),
|
|
110
|
+
timeoutSeconds: 120,
|
|
111
|
+
});
|
|
112
|
+
const stdout = String(result.stdout || '');
|
|
113
|
+
const stderr = String(result.stderr || '');
|
|
114
|
+
this.owner.evidence.execCount += 1;
|
|
115
|
+
if (/(?:OPENAI_XAPI_SANDBOX_OK|SDK_OK)=42/.test(`${stdout}\n${stderr}`)) {
|
|
116
|
+
this.owner.evidence.shellMarkerSeen = true;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
output: [stdout, stderr].filter(Boolean).join('\n'),
|
|
120
|
+
stdout,
|
|
121
|
+
stderr,
|
|
122
|
+
exitCode: typeof result.exitCode === 'number' ? result.exitCode : null,
|
|
123
|
+
wallTimeSeconds: (Date.now() - before) / 1_000,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async execCommand(args: ExecCommandArgs) {
|
|
128
|
+
return (await this.exec(args)).output;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async stop() { await this.close(); }
|
|
132
|
+
async shutdown() { await this.close(); }
|
|
133
|
+
async delete() { await this.close(); }
|
|
134
|
+
|
|
135
|
+
async close() {
|
|
136
|
+
if (this.closed) return;
|
|
137
|
+
if (this.closePromise) return this.closePromise;
|
|
138
|
+
this.closePromise = this.owner.terminate(this.state);
|
|
139
|
+
try {
|
|
140
|
+
await this.closePromise;
|
|
141
|
+
this.closed = true;
|
|
142
|
+
} finally {
|
|
143
|
+
this.closePromise = undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Minimal provider adapter for Shell-based SandboxAgent examples.
|
|
150
|
+
*
|
|
151
|
+
* It intentionally rejects materialized Manifest entries and environment
|
|
152
|
+
* values. A production adapter should add file/mount/snapshot translations
|
|
153
|
+
* instead of pretending those optional surfaces work.
|
|
154
|
+
*/
|
|
155
|
+
export class XapiAgentsSandboxClient implements AgentsSandboxClient<RunOptions, XapiAgentsSandboxState> {
|
|
156
|
+
readonly backendId = 'xapi-sandbox';
|
|
157
|
+
readonly supportsDefaultOptions = true;
|
|
158
|
+
readonly evidence: XapiAgentsSandboxEvidence = { execCount: 0, shellMarkerSeen: false };
|
|
159
|
+
readonly workspaceRoot: string;
|
|
160
|
+
lastSession?: XapiAgentsSandboxSession;
|
|
161
|
+
|
|
162
|
+
private readonly apiKey: string;
|
|
163
|
+
private readonly sandboxHost: string;
|
|
164
|
+
private readonly provider: string;
|
|
165
|
+
private readonly maxHourlyUsd: number;
|
|
166
|
+
private readonly model: string;
|
|
167
|
+
private readonly terminationKeys = new Map<string, string>();
|
|
168
|
+
|
|
169
|
+
constructor(options: XapiAgentsSandboxOptions) {
|
|
170
|
+
if (!options.apiKey) throw new Error('XapiAgentsSandboxClient requires apiKey');
|
|
171
|
+
this.apiKey = options.apiKey;
|
|
172
|
+
this.sandboxHost = options.sandboxHost || 'sandbox.xapi.to';
|
|
173
|
+
this.provider = options.provider || 'daytona';
|
|
174
|
+
this.maxHourlyUsd = positivePrice(options.maxHourlyUsd ?? 0.20, 'maxHourlyUsd');
|
|
175
|
+
this.model = options.model || 'deepseek-v4-pro';
|
|
176
|
+
this.workspaceRoot = options.workspaceRoot || defaultWorkspaceRoot(this.provider);
|
|
177
|
+
shellQuote(this.workspaceRoot);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async create(
|
|
181
|
+
args: SandboxClientCreateArgs<RunOptions> | Manifest = {},
|
|
182
|
+
legacyOptions?: RunOptions,
|
|
183
|
+
): Promise<XapiAgentsSandboxSession> {
|
|
184
|
+
const normalized = normalizeSandboxClientCreateArgs(args, legacyOptions);
|
|
185
|
+
const provider = normalized.options?.provider || this.provider;
|
|
186
|
+
const maxHourlyUsd = positivePrice(
|
|
187
|
+
normalized.options?.maxHourlyUsd ?? this.maxHourlyUsd,
|
|
188
|
+
'maxHourlyUsd',
|
|
189
|
+
);
|
|
190
|
+
const manifest = normalized.manifest;
|
|
191
|
+
if (Object.keys(manifest.validatedEntries()).length !== 0) {
|
|
192
|
+
throw new Error('XapiAgentsSandboxClient example currently supports an empty Manifest only');
|
|
193
|
+
}
|
|
194
|
+
if (Object.keys(manifest.environment).length !== 0) {
|
|
195
|
+
throw new Error('XapiAgentsSandboxClient keeps credentials/environment out of the Manifest');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const options: SandboxClientOptions = {
|
|
199
|
+
sandboxHost: this.sandboxHost,
|
|
200
|
+
apiKey: this.apiKey,
|
|
201
|
+
provider,
|
|
202
|
+
};
|
|
203
|
+
const quote = await sandboxQuote(options, {
|
|
204
|
+
requirements: { capabilities: ['exec', 'files'] },
|
|
205
|
+
maxEstimatedHourlyUsd: maxHourlyUsd.toFixed(8),
|
|
206
|
+
});
|
|
207
|
+
if (!quote?.quoteId) throw new Error('xAPI Sandbox quote did not return quoteId');
|
|
208
|
+
const created = await sandboxCreate(options, {
|
|
209
|
+
selection: { quoteId: quote.quoteId },
|
|
210
|
+
metadata: { client: 'openai-agents-sdk', modelGateway: 'ai.xapi.to', model: this.model },
|
|
211
|
+
idempotencyKey: `openai-agents-sdk:${randomUUID()}`,
|
|
212
|
+
});
|
|
213
|
+
if (!created.id) throw new Error('xAPI Sandbox create did not return instance id');
|
|
214
|
+
|
|
215
|
+
const state: XapiAgentsSandboxState = {
|
|
216
|
+
manifest,
|
|
217
|
+
workspaceReady: false,
|
|
218
|
+
instanceId: created.id,
|
|
219
|
+
provider,
|
|
220
|
+
};
|
|
221
|
+
const session = new XapiAgentsSandboxSession(state, options, this);
|
|
222
|
+
this.lastSession = session;
|
|
223
|
+
this.evidence.instanceId = created.id;
|
|
224
|
+
this.evidence.provider = provider;
|
|
225
|
+
try {
|
|
226
|
+
await sandboxWait(options, created.id, ['RUNNING'], 360_000, 2_000);
|
|
227
|
+
const prepared = await session.exec({ cmd: `mkdir -p -- ${shellQuote(manifest.root)}` });
|
|
228
|
+
if (prepared.exitCode !== 0) throw new Error(`could not prepare ${manifest.root}`);
|
|
229
|
+
state.workspaceReady = true;
|
|
230
|
+
return session;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
await this.terminate(state).catch(() => undefined);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async delete(state: XapiAgentsSandboxState) {
|
|
238
|
+
await this.terminate(state);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async terminate(state: XapiAgentsSandboxState) {
|
|
242
|
+
const options: SandboxClientOptions = {
|
|
243
|
+
sandboxHost: this.sandboxHost,
|
|
244
|
+
apiKey: this.apiKey,
|
|
245
|
+
provider: state.provider,
|
|
246
|
+
};
|
|
247
|
+
const aggregateOptions = { ...options, provider: undefined };
|
|
248
|
+
const readDetail = async () => {
|
|
249
|
+
try {
|
|
250
|
+
return await sandboxGet(options, state.instanceId);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (!(error instanceof HttpError) || error.status !== 404 || !options.provider) throw error;
|
|
253
|
+
return sandboxGet(aggregateOptions, state.instanceId);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const deadline = Date.now() + 360_000;
|
|
257
|
+
const intervalMs = 2_000;
|
|
258
|
+
const terminationKey = this.terminationKeys.get(state.instanceId)
|
|
259
|
+
|| `openai-agents-sdk:terminate:${randomUUID()}`;
|
|
260
|
+
this.terminationKeys.set(state.instanceId, terminationKey);
|
|
261
|
+
let detail = await readDetail();
|
|
262
|
+
while (!['TERMINATED', 'FAILED'].includes(String(detail.observedState)) && Date.now() < deadline) {
|
|
263
|
+
try {
|
|
264
|
+
await sandboxStateAction(options, state.instanceId, 'terminate', {
|
|
265
|
+
idempotencyKey: terminationKey,
|
|
266
|
+
});
|
|
267
|
+
break;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!(error instanceof HttpError) || error.status !== 409) throw error;
|
|
270
|
+
await sleep(intervalMs);
|
|
271
|
+
detail = await readDetail();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!['TERMINATED', 'FAILED'].includes(String(detail.observedState))) {
|
|
275
|
+
try {
|
|
276
|
+
detail = await sandboxWait(
|
|
277
|
+
options,
|
|
278
|
+
state.instanceId,
|
|
279
|
+
['TERMINATED', 'FAILED'],
|
|
280
|
+
Math.max(1, deadline - Date.now()),
|
|
281
|
+
intervalMs,
|
|
282
|
+
);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (!(error instanceof HttpError) || error.status !== 404 || !options.provider) throw error;
|
|
285
|
+
detail = await sandboxWait(
|
|
286
|
+
aggregateOptions,
|
|
287
|
+
state.instanceId,
|
|
288
|
+
['TERMINATED', 'FAILED'],
|
|
289
|
+
Math.max(1, deadline - Date.now()),
|
|
290
|
+
intervalMs,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const auditDeadline = Date.now() + 60_000;
|
|
296
|
+
let audit: Record<string, unknown> = {};
|
|
297
|
+
let auditError = 'audit has not settled';
|
|
298
|
+
while (Date.now() < auditDeadline) {
|
|
299
|
+
detail = await sandboxGet(aggregateOptions, state.instanceId);
|
|
300
|
+
audit = {};
|
|
301
|
+
for (const kind of ['operations', 'events', 'usageSegments', 'billingPeriods']) {
|
|
302
|
+
audit[kind] = await sandboxAudit(aggregateOptions, state.instanceId, kind);
|
|
303
|
+
}
|
|
304
|
+
const operations = items(audit.operations);
|
|
305
|
+
const events = items(audit.events);
|
|
306
|
+
const usageSegments = items(audit.usageSegments);
|
|
307
|
+
const billingPeriods = items(audit.billingPeriods);
|
|
308
|
+
const operationStatuses = operations.map((item) => String(item.status || 'UNKNOWN'));
|
|
309
|
+
const operationSettled = operations.length > 0
|
|
310
|
+
&& operationStatuses.every((status) => ['SUCCEEDED', 'FAILED'].includes(status));
|
|
311
|
+
const eventSettled = events.some((item) => ['TERMINATED', 'FAILED'].includes(String(item.currentState)));
|
|
312
|
+
const usageSettled = usageSegments.length > 0
|
|
313
|
+
&& usageSegments.every((item) => item.status === 'SETTLED' && item.endsAt);
|
|
314
|
+
const billingSettled = billingPeriods.length > 0
|
|
315
|
+
&& billingPeriods.every((item) => item.status === 'SETTLED' && item.endedAt);
|
|
316
|
+
const billed = billingPeriods.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
|
317
|
+
const totalCost = Number(detail.totalCost);
|
|
318
|
+
const costMatches = Number.isFinite(totalCost) && Number.isFinite(billed)
|
|
319
|
+
&& Math.abs(totalCost - billed) <= 1e-9;
|
|
320
|
+
if (operationSettled && eventSettled && usageSettled && billingSettled && costMatches) {
|
|
321
|
+
auditError = '';
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
auditError = [
|
|
325
|
+
!operationSettled && 'operations are not terminal',
|
|
326
|
+
!eventSettled && 'terminal event is missing',
|
|
327
|
+
!usageSettled && 'usage is not settled',
|
|
328
|
+
!billingSettled && 'billing is not settled',
|
|
329
|
+
!costMatches && `billing sum ${billed} does not match totalCost ${detail.totalCost}`,
|
|
330
|
+
].filter(Boolean).join('; ');
|
|
331
|
+
await sleep(1_000);
|
|
332
|
+
}
|
|
333
|
+
if (auditError) throw new Error(`xAPI Sandbox audit verification failed: ${auditError}`);
|
|
334
|
+
|
|
335
|
+
const auditCounts: Record<string, number> = {};
|
|
336
|
+
const auditStatuses: Record<string, string[]> = {};
|
|
337
|
+
for (const kind of ['operations', 'events', 'usageSegments', 'billingPeriods']) {
|
|
338
|
+
auditCounts[kind] = countItems(audit[kind]);
|
|
339
|
+
auditStatuses[kind] = items(audit[kind]).map((item) => String(
|
|
340
|
+
item.status || item.currentState || 'UNKNOWN',
|
|
341
|
+
));
|
|
342
|
+
}
|
|
343
|
+
this.evidence.finalState = detail.observedState;
|
|
344
|
+
this.evidence.totalCost = detail.totalCost;
|
|
345
|
+
this.evidence.auditCounts = auditCounts;
|
|
346
|
+
this.evidence.auditStatuses = auditStatuses;
|
|
347
|
+
this.evidence.auditVerified = true;
|
|
348
|
+
}
|
|
349
|
+
}
|