@tangle-network/chatgpt-agents-kit 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/README.md +416 -0
- package/SETUP.md +211 -0
- package/dist/inspection/skills/tangle-agent-run-inspection/SKILL.md +22 -0
- package/dist/inspection/src/comparison.d.ts +91 -0
- package/dist/inspection/src/comparison.js +108 -0
- package/dist/inspection/src/core.d.ts +166 -0
- package/dist/inspection/src/core.js +236 -0
- package/dist/inspection/src/index.d.ts +36 -0
- package/dist/inspection/src/index.js +114 -0
- package/dist/skills/continue-agent-in-channel/SKILL.md +26 -0
- package/dist/skills/handoff-to-agent/SKILL.md +42 -0
- package/dist/skills/operate-existing-agent/SKILL.md +117 -0
- package/dist/skills/resolve-agent-decisions/SKILL.md +25 -0
- package/dist/skills/resume-agent-work/SKILL.md +32 -0
- package/dist/skills/review-agent-deliverables/SKILL.md +36 -0
- package/dist/skills/save-agent-playbook/SKILL.md +32 -0
- package/dist/src/app-metadata.d.ts +25 -0
- package/dist/src/app-metadata.js +34 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/connection.d.ts +10 -0
- package/dist/src/connection.js +25 -0
- package/dist/src/contracts.d.ts +161 -0
- package/dist/src/contracts.js +4 -0
- package/dist/src/events-node.d.ts +9 -0
- package/dist/src/events-node.js +128 -0
- package/dist/src/gtm.d.ts +19 -0
- package/dist/src/gtm.js +85 -0
- package/dist/src/handoff.d.ts +4 -0
- package/dist/src/handoff.js +112 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +6 -0
- package/dist/src/inspection.d.ts +14 -0
- package/dist/src/inspection.js +40 -0
- package/dist/src/kit.d.ts +21 -0
- package/dist/src/kit.js +525 -0
- package/dist/src/package.d.ts +28 -0
- package/dist/src/package.js +259 -0
- package/dist/src/sandbox-agent.d.ts +35 -0
- package/dist/src/sandbox-agent.js +178 -0
- package/dist/src/task-events.d.ts +238 -0
- package/dist/src/task-events.js +268 -0
- package/dist/src/task-wait.d.ts +26 -0
- package/dist/src/task-wait.js +61 -0
- package/dist/src/workflows.d.ts +19 -0
- package/dist/src/workflows.js +38 -0
- package/package.json +58 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { AGENT_APP_METADATA_KEY } from "./app-metadata.js";
|
|
6
|
+
import { ACTION_TOOLS, selectAgentWorkflows, describeAgentWorkflows } from "./workflows.js";
|
|
7
|
+
export function endpointUrl(value, allowLoopback = false) {
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
if (url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(allowLoopback && url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))))
|
|
10
|
+
throw new Error('A credential-free HTTPS MCP endpoint is required');
|
|
11
|
+
return url;
|
|
12
|
+
}
|
|
13
|
+
export function validateMetadata(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
15
|
+
throw new Error('Metadata must be an object');
|
|
16
|
+
const m = value;
|
|
17
|
+
const keys = ['name', 'displayName', 'description', 'applicationOrigin', 'actions', 'inspection'];
|
|
18
|
+
if (Object.keys(m).some(k => !keys.includes(k)))
|
|
19
|
+
throw new Error('Unknown metadata field; credentials and auth headers are never package metadata');
|
|
20
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(m.name) || m.name.length > 64)
|
|
21
|
+
throw new Error('Invalid plugin name');
|
|
22
|
+
for (const value of [m.displayName, m.description])
|
|
23
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 300 || /[\r\n\x00]/.test(value))
|
|
24
|
+
throw new Error('Invalid display metadata');
|
|
25
|
+
const origin = endpointUrl(m.applicationOrigin, true);
|
|
26
|
+
if (origin.pathname !== '/')
|
|
27
|
+
throw new Error('applicationOrigin must be an origin');
|
|
28
|
+
const actions = Object.keys(ACTION_TOOLS);
|
|
29
|
+
if (!Array.isArray(m.actions) || !m.actions.length || m.actions.some(a => !actions.includes(a)) || new Set(m.actions).size !== m.actions.length)
|
|
30
|
+
throw new Error('Invalid action restriction');
|
|
31
|
+
if (m.inspection !== undefined && typeof m.inspection !== 'boolean')
|
|
32
|
+
throw new Error('Invalid inspection flag');
|
|
33
|
+
return m;
|
|
34
|
+
}
|
|
35
|
+
/** Anonymous, read-only readiness check. Does not register an OAuth client or deploy anything. */
|
|
36
|
+
export async function probeOAuth(endpoint, request = fetch) {
|
|
37
|
+
const resource = endpointUrl(endpoint, true);
|
|
38
|
+
const res = await request(resource, { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'tangle-agents-preflight', version: '0.1.0' } } }), signal: AbortSignal.timeout(10000) });
|
|
39
|
+
if (res.status !== 401)
|
|
40
|
+
throw new Error(`MCP must challenge unauthenticated requests; observed HTTP ${res.status}`);
|
|
41
|
+
const metadataUrl = /resource_metadata="([^"]+)"/.exec(res.headers.get('www-authenticate') ?? '')?.[1];
|
|
42
|
+
const expected = `${resource.origin}/.well-known/oauth-protected-resource${resource.pathname}`;
|
|
43
|
+
if (metadataUrl !== expected)
|
|
44
|
+
throw new Error('Missing or unexpected protected-resource discovery URL');
|
|
45
|
+
const metadataResponse = await request(metadataUrl, { redirect: 'error', signal: AbortSignal.timeout(10000) });
|
|
46
|
+
if (!metadataResponse.ok)
|
|
47
|
+
throw new Error('Protected-resource metadata unavailable');
|
|
48
|
+
const metadata = await readDiscovery(metadataResponse);
|
|
49
|
+
if (metadata.resource !== resource.href || !Array.isArray(metadata.authorization_servers) || !metadata.authorization_servers.length)
|
|
50
|
+
throw new Error('Resource audience or authorization server missing');
|
|
51
|
+
// Do not follow an arbitrary issuer supplied by a discovery document. This
|
|
52
|
+
// report is discovery-only; the operator must verify the configured issuer,
|
|
53
|
+
// S256 support and authorization-code flow before connecting ChatGPT.
|
|
54
|
+
for (const issuer of metadata.authorization_servers) {
|
|
55
|
+
if (typeof issuer !== 'string')
|
|
56
|
+
throw new Error('Invalid authorization server');
|
|
57
|
+
endpointUrl(issuer, true);
|
|
58
|
+
}
|
|
59
|
+
return { anonymousStatus: res.status, resource: resource.href, protectedResourceMetadata: metadataUrl, authorizationServers: metadata.authorization_servers, oauthLogin: 'not-executed', capabilities: 'not-discovered',
|
|
60
|
+
...(metadata[AGENT_APP_METADATA_KEY] !== undefined ? { app: metadata[AGENT_APP_METADATA_KEY] } : {}) };
|
|
61
|
+
}
|
|
62
|
+
/** A registered connection is a developer input, never OAuth or login proof. */
|
|
63
|
+
function registeredConnectionId(value) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (!/^(?:(?:plugin_)?asdk_app_|connector_|templated_apps_)[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value)) {
|
|
67
|
+
throw new Error('Expected a registered ChatGPT connection ID (plugin_asdk_app_..., asdk_app_..., connector_... or templated_apps_...). Copy the real ID after registering this endpoint; not an OAuth client ID, token or URL');
|
|
68
|
+
}
|
|
69
|
+
// ChatGPT's browser URL uses the plugin ID; .app.json uses the app ID.
|
|
70
|
+
return value.replace(/^plugin_(?=asdk_app_)/, '');
|
|
71
|
+
}
|
|
72
|
+
export async function buildPackage(metadata, endpoint, out, offline = false, connectionId) {
|
|
73
|
+
const registered = registeredConnectionId(connectionId);
|
|
74
|
+
const m = validateMetadata(metadata);
|
|
75
|
+
const url = endpointUrl(endpoint, true);
|
|
76
|
+
const readiness = offline ? { oauthLogin: 'not-executed', discovery: 'not-executed', capabilities: 'not-discovered' } : await probeOAuth(url.href);
|
|
77
|
+
return writePackage(m, url, out, readiness, registered);
|
|
78
|
+
}
|
|
79
|
+
/** Read the selected endpoint's exact discovery document; no second app.json. */
|
|
80
|
+
export async function buildPackageFromEndpoint(endpoint, out, request = fetch, connectionId) {
|
|
81
|
+
const registered = registeredConnectionId(connectionId);
|
|
82
|
+
const { metadata, url, readiness } = await inspectEndpoint(endpoint, request);
|
|
83
|
+
return writePackage(metadata, url, out, readiness, registered);
|
|
84
|
+
}
|
|
85
|
+
/** One discovery/metadata path for generation and read-only setup diagnostics. */
|
|
86
|
+
async function inspectEndpoint(endpoint, request = fetch) {
|
|
87
|
+
const { app, ...readiness } = await probeOAuth(endpoint, request);
|
|
88
|
+
if (!app || typeof app !== 'object' || Array.isArray(app))
|
|
89
|
+
throw new Error('This endpoint does not publish Agent App metadata; use --config with its reviewed app configuration');
|
|
90
|
+
const { formatVersion, ...description } = app;
|
|
91
|
+
if (formatVersion !== 1)
|
|
92
|
+
throw new Error('Unsupported Agent App metadata version');
|
|
93
|
+
const metadata = validateMetadata(description);
|
|
94
|
+
const url = endpointUrl(endpoint, true);
|
|
95
|
+
if (metadata.applicationOrigin !== url.origin)
|
|
96
|
+
throw new Error('Discovered application origin does not match the selected endpoint');
|
|
97
|
+
return { metadata, url, readiness };
|
|
98
|
+
}
|
|
99
|
+
async function readDiscovery(response) {
|
|
100
|
+
const reader = response.body?.getReader();
|
|
101
|
+
if (!reader)
|
|
102
|
+
throw new Error('Protected-resource metadata unavailable');
|
|
103
|
+
const chunks = [];
|
|
104
|
+
let size = 0;
|
|
105
|
+
try {
|
|
106
|
+
for (;;) {
|
|
107
|
+
const { done, value } = await reader.read();
|
|
108
|
+
if (done)
|
|
109
|
+
break;
|
|
110
|
+
size += value.byteLength;
|
|
111
|
+
if (size > 65536)
|
|
112
|
+
throw new Error('Protected-resource metadata is too large');
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await reader.cancel().catch(() => { });
|
|
118
|
+
reader.releaseLock();
|
|
119
|
+
}
|
|
120
|
+
const bytes = new Uint8Array(size);
|
|
121
|
+
let offset = 0;
|
|
122
|
+
for (const chunk of chunks) {
|
|
123
|
+
bytes.set(chunk, offset);
|
|
124
|
+
offset += chunk.byteLength;
|
|
125
|
+
}
|
|
126
|
+
const parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
127
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
128
|
+
throw new Error('Invalid protected-resource metadata');
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
131
|
+
function outputExists(destination) {
|
|
132
|
+
return new Error(`Output already exists at ${destination}; choose a new --out directory. Existing files were not changed`);
|
|
133
|
+
}
|
|
134
|
+
async function requireNewOutput(destination) {
|
|
135
|
+
try {
|
|
136
|
+
await lstat(destination);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error.code === 'ENOENT')
|
|
140
|
+
return;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
throw outputExists(destination);
|
|
144
|
+
}
|
|
145
|
+
async function writePackage(m, url, out, discovered, connectionId) {
|
|
146
|
+
const { app: _displayOnly, ...readiness } = discovered;
|
|
147
|
+
if (connectionId)
|
|
148
|
+
readiness.chatgptConnection = { id: connectionId, status: 'developer-supplied-unverified' };
|
|
149
|
+
const workflows = selectAgentWorkflows(m.actions);
|
|
150
|
+
const destination = resolve(out);
|
|
151
|
+
await requireNewOutput(destination);
|
|
152
|
+
const temporary = `${destination}.building-${crypto.randomUUID()}`;
|
|
153
|
+
// build-assets.mjs mirrors these module-relative assets into dist. Never
|
|
154
|
+
// resolve against the adopter's cwd or fall back to a source checkout.
|
|
155
|
+
const skill = await readFile(new URL('../skills/operate-existing-agent/SKILL.md', import.meta.url), 'utf8');
|
|
156
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
157
|
+
await mkdir(temporary);
|
|
158
|
+
try {
|
|
159
|
+
await mkdir(join(temporary, 'skills/operate-existing-agent'), { recursive: true });
|
|
160
|
+
await writeFile(join(temporary, 'plugin.json'), JSON.stringify({
|
|
161
|
+
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', name: m.name, version: '0.1.0', description: m.description,
|
|
162
|
+
extensions: { 'com.openai': { ...(connectionId ? { apps: './.app.json' } : {}), interface: { displayName: m.displayName, shortDescription: m.description, developerName: 'Tangle', category: 'Productivity', websiteURL: m.applicationOrigin, defaultPrompt: ['Connect my agent', ...workflows.slice(0, 2).map(workflow => workflow.prompt)] } } },
|
|
163
|
+
}, null, 2) + '\n');
|
|
164
|
+
if (connectionId)
|
|
165
|
+
await writeFile(join(temporary, '.app.json'), JSON.stringify({ apps: { agents: { id: connectionId } } }, null, 2) + '\n');
|
|
166
|
+
await writeFile(join(temporary, 'mcp.json'), JSON.stringify({ $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', mcpServers: { agents: { type: 'streamable-http', url: url.href } } }, null, 2) + '\n');
|
|
167
|
+
await writeFile(join(temporary, 'skills/operate-existing-agent/SKILL.md'), skill);
|
|
168
|
+
for (const workflow of workflows) {
|
|
169
|
+
const path = `skills/${workflow.id}/SKILL.md`;
|
|
170
|
+
await mkdir(join(temporary, 'skills', workflow.id));
|
|
171
|
+
await writeFile(join(temporary, path), await readFile(new URL(`../${path}`, import.meta.url)));
|
|
172
|
+
}
|
|
173
|
+
await writeFile(join(temporary, 'agent-workflows.json'), JSON.stringify({
|
|
174
|
+
formatVersion: 1, authority: 'packaging-restrictions-only',
|
|
175
|
+
runtimeDiscoveryRequired: true, workflows: describeAgentWorkflows(m.actions),
|
|
176
|
+
}, null, 2) + '\n');
|
|
177
|
+
if (m.inspection) {
|
|
178
|
+
await mkdir(join(temporary, 'skills/tangle-agent-run-inspection'));
|
|
179
|
+
await writeFile(join(temporary, 'skills/tangle-agent-run-inspection/SKILL.md'), await readFile(new URL('../inspection/skills/tangle-agent-run-inspection/SKILL.md', import.meta.url)));
|
|
180
|
+
}
|
|
181
|
+
await writeFile(join(temporary, 'README.md'), [
|
|
182
|
+
'# Connect this Agent App', '',
|
|
183
|
+
'Your agent, tasks, files and billing stay in the original Tangle app.', '',
|
|
184
|
+
'1. In ChatGPT, enable developer mode and add an MCP connection.',
|
|
185
|
+
`2. Use this server URL: ${url.href}`,
|
|
186
|
+
'3. Sign in to the app and review its requested permissions.',
|
|
187
|
+
connectionId
|
|
188
|
+
? `4. Verify that .app.json maps this endpoint's registered connection: ${connectionId}. This mapping is developer-supplied, not verified by the generator.`
|
|
189
|
+
: '4. Copy the real registered connection ID from ChatGPT. Rerun the installed generator with --connection-id and a new --out directory to add its .app.json mapping.',
|
|
190
|
+
'5. Import/install that generated package using the current ChatGPT plugin instructions; test in a new chat.',
|
|
191
|
+
'6. Ask: Connect my agent. Select the SAME existing agent, workspace and conversation used in the app.', '',
|
|
192
|
+
'Prompt the selected agent; use get_task to observe the same turn.',
|
|
193
|
+
'Reopen results with read_output and the returned native references.',
|
|
194
|
+
'A queued receipt is not completion. Missing capabilities stay unavailable.', '',
|
|
195
|
+
'Generation does not install ChatGPT, register OAuth or run a task.', '',
|
|
196
|
+
'## Recovery', '',
|
|
197
|
+
'Run tangle-agents-plugin --check --endpoint with the exact server URL above for read-only discovery diagnostics.',
|
|
198
|
+
'A successful check is not OAuth login, tool dispatch, task completion, output retrieval, reconnect or customer-isolation proof.',
|
|
199
|
+
'For changed metadata, skills or connection IDs, generate to a new --out directory; existing packages are never edited in place.',
|
|
200
|
+
'Refresh hosted tool metadata separately from reinstalling changed package skills. Reconnect with the original native IDs; do not create a replacement agent.',
|
|
201
|
+
'Do not use --offline to bypass a failed online check. Keep the app owner responsible for OAuth and native admission.',
|
|
202
|
+
'The installed kit includes SETUP.md with the complete app-neutral setup, example and recovery flow.', '',
|
|
203
|
+
].join('\n'));
|
|
204
|
+
await writeFile(join(temporary, 'readiness.json'), JSON.stringify({ formatVersion: 1, ...readiness, hostedTask: 'not-executed', chatgptInstall: 'not-executed' }, null, 2) + '\n');
|
|
205
|
+
// rename does not replace a nonempty existing package; no in-place mutation.
|
|
206
|
+
await rename(temporary, destination);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
await rm(temporary, { recursive: true, force: true });
|
|
210
|
+
if (['EEXIST', 'ENOTEMPTY'].includes(error.code ?? ''))
|
|
211
|
+
throw outputExists(destination);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
return { directory: destination, readiness };
|
|
215
|
+
}
|
|
216
|
+
/** Shared by the installed compiled bin and the existing source entry point. */
|
|
217
|
+
export async function runPackageCli(args = process.argv.slice(2)) {
|
|
218
|
+
const usage = [
|
|
219
|
+
'Usage: tangle-agents-plugin [--config app.json] --endpoint https://APP/mcp [--out ./chatgpt-plugin] [--offline] [--connection-id ID]',
|
|
220
|
+
' tangle-agents-plugin --check --endpoint https://APP/mcp',
|
|
221
|
+
'--check validates anonymous discovery and app metadata without writing a package or calling native tools.',
|
|
222
|
+
'--connection-id uses the real registered ChatGPT ID; it does not register or verify that connection.',
|
|
223
|
+
'Use a new --out directory on reruns. See node_modules/@tangle-network/chatgpt-agents-kit/SETUP.md.',
|
|
224
|
+
].join('\n');
|
|
225
|
+
try {
|
|
226
|
+
const { values } = parseArgs({ args, options: { config: { type: 'string' }, endpoint: { type: 'string' }, out: { type: 'string' }, offline: { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, 'connection-id': { type: 'string' }, help: { type: 'boolean', short: 'h' } } });
|
|
227
|
+
if (values.help) {
|
|
228
|
+
console.log(usage);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!values.endpoint)
|
|
232
|
+
throw new Error(usage);
|
|
233
|
+
if (values.check) {
|
|
234
|
+
if (values.config !== undefined || values.out !== undefined || values.offline || values['connection-id'] !== undefined)
|
|
235
|
+
throw new Error('--check takes only --endpoint; it does not generate or bind a package');
|
|
236
|
+
const { metadata, url, readiness } = await inspectEndpoint(values.endpoint);
|
|
237
|
+
console.log(JSON.stringify({ endpoint: url.href, metadata,
|
|
238
|
+
readiness: { ...readiness, hostedTask: 'not-executed', chatgptInstall: 'not-executed' },
|
|
239
|
+
packageGeneration: 'not-executed',
|
|
240
|
+
nextStep: 'Register or verify this exact endpoint in ChatGPT, then generate with --connection-id and a new --out directory.',
|
|
241
|
+
}, null, 2));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (values.offline && !values.config)
|
|
245
|
+
throw new Error('--offline requires --config; endpoint metadata was not discovered');
|
|
246
|
+
const out = values.out ?? './chatgpt-plugin';
|
|
247
|
+
const result = values.config
|
|
248
|
+
? await buildPackage(JSON.parse(await readFile(values.config, 'utf8')), values.endpoint, out, values.offline, values['connection-id'])
|
|
249
|
+
: await buildPackageFromEndpoint(values.endpoint, out, fetch, values['connection-id']);
|
|
250
|
+
console.log(JSON.stringify(result, null, 2));
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
console.error(error instanceof Error ? error.message : 'Package generation failed');
|
|
254
|
+
process.exitCode = 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
258
|
+
await runPackageCli();
|
|
259
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Sandbox } from '@tangle-network/sandbox/core';
|
|
2
|
+
import type { NativeBinding, NativeServices, Target } from './contracts.ts';
|
|
3
|
+
/** The application's EXISTING enrollment. Never derive sandbox/session ids from model input.
|
|
4
|
+
* Keep an existing thread pinned to this incarnation; replacing it requires an explicit
|
|
5
|
+
* application migration/new thread, not an automatic ensure() during a prompt. */
|
|
6
|
+
export interface SandboxAgentTarget extends Target {
|
|
7
|
+
agentRef: string;
|
|
8
|
+
sandboxId: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
filesystemIncarnationId: string;
|
|
11
|
+
instance?: {
|
|
12
|
+
key: string;
|
|
13
|
+
generation: number;
|
|
14
|
+
};
|
|
15
|
+
profileVersion?: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface SandboxAgentContext {
|
|
18
|
+
platformUserId: string;
|
|
19
|
+
applicationUserId: string;
|
|
20
|
+
appName: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
/** User-scoped SDK, supplied by the native host. No shared privileged client. */
|
|
23
|
+
client: Pick<Sandbox, 'get' | 'instances'>;
|
|
24
|
+
requiredScopes: NativeBinding['requiredScopes'];
|
|
25
|
+
/** Recheck the original grant and the application's current target permissions. */
|
|
26
|
+
authorize: NativeBinding['authorize'];
|
|
27
|
+
list: NativeServices['list'];
|
|
28
|
+
resolve(target: Target): Promise<SandboxAgentTarget>;
|
|
29
|
+
}
|
|
30
|
+
/** Concrete SDK binding for an already-provisioned generic Sandbox agent.
|
|
31
|
+
* NOT a replacement for GTM/Creative's application-owned preparation/billing/approval path.
|
|
32
|
+
* The native session message lane owns durable admission, execution and retention.
|
|
33
|
+
* There is no plugin task database, ensure/create, worker loop, or request-owned execution.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createSandboxAgentBinding(context: SandboxAgentContext): NativeBinding;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describeConnection } from "./connection.js";
|
|
2
|
+
import { AgentFailure } from "./contracts.js";
|
|
3
|
+
const id = (value) => typeof value === 'string' && /^[A-Za-z0-9_-]{1,256}$/.test(value);
|
|
4
|
+
function environment(enrollment, box) {
|
|
5
|
+
return { kind: 'persistent-sandbox', sandboxId: enrollment.sandboxId, sessionId: enrollment.sessionId,
|
|
6
|
+
...(box.filesystemIncarnationId ? { filesystemIncarnationId: box.filesystemIncarnationId } : {}),
|
|
7
|
+
...(enrollment.instance ? { instanceKey: enrollment.instance.key, generation: enrollment.instance.generation } : {}),
|
|
8
|
+
...(enrollment.profileVersion !== undefined ? { profileVersion: enrollment.profileVersion } : {}) };
|
|
9
|
+
}
|
|
10
|
+
/** Concrete SDK binding for an already-provisioned generic Sandbox agent.
|
|
11
|
+
* NOT a replacement for GTM/Creative's application-owned preparation/billing/approval path.
|
|
12
|
+
* The native session message lane owns durable admission, execution and retention.
|
|
13
|
+
* There is no plugin task database, ensure/create, worker loop, or request-owned execution.
|
|
14
|
+
*/
|
|
15
|
+
export function createSandboxAgentBinding(context) {
|
|
16
|
+
async function locate(target, action) {
|
|
17
|
+
await context.authorize(target, action);
|
|
18
|
+
const resolved = await context.resolve(target);
|
|
19
|
+
// Snapshot the enrollment before any I/O; host caches may reuse mutable objects.
|
|
20
|
+
const enrollment = resolved && { ...resolved, ...(resolved.instance ? { instance: { ...resolved.instance } } : {}) };
|
|
21
|
+
if (!enrollment || enrollment.workspaceId !== target.workspaceId || enrollment.threadId !== target.threadId
|
|
22
|
+
|| !id(enrollment.sandboxId) || !id(enrollment.sessionId) || !enrollment.agentRef || typeof enrollment.filesystemIncarnationId !== 'string' || !enrollment.filesystemIncarnationId
|
|
23
|
+
|| (enrollment.instance && !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,199}$/.test(enrollment.instance.key))
|
|
24
|
+
|| (enrollment.instance && enrollment.profileVersion === undefined)
|
|
25
|
+
|| (enrollment.instance && (!Number.isSafeInteger(enrollment.instance.generation) || enrollment.instance.generation < 0)))
|
|
26
|
+
throw new AgentFailure('agent_enrollment_invalid', 502);
|
|
27
|
+
if (enrollment.instance) {
|
|
28
|
+
const instance = await context.client.instances.get(enrollment.instance.key);
|
|
29
|
+
if (!instance || instance.sandboxId !== enrollment.sandboxId || instance.generation !== enrollment.instance.generation
|
|
30
|
+
|| instance.profileVersion !== enrollment.profileVersion)
|
|
31
|
+
throw new AgentFailure('agent_environment_changed_reconnect_required', 409);
|
|
32
|
+
}
|
|
33
|
+
const box = await context.client.get(enrollment.sandboxId);
|
|
34
|
+
if (!box || box.id !== enrollment.sandboxId)
|
|
35
|
+
throw new AgentFailure('agent_environment_unavailable', 409);
|
|
36
|
+
if (enrollment.filesystemIncarnationId && box.filesystemIncarnationId !== enrollment.filesystemIncarnationId)
|
|
37
|
+
throw new AgentFailure('agent_filesystem_changed_reconnect_required', 409);
|
|
38
|
+
// Validate metadata before a prompt can cause any effect.
|
|
39
|
+
await describeConnection({ agentRef: enrollment.agentRef, environment: environment(enrollment, box) });
|
|
40
|
+
await context.authorize(target, action);
|
|
41
|
+
return { box, enrollment };
|
|
42
|
+
}
|
|
43
|
+
async function requireSession(box, sessionId) {
|
|
44
|
+
const info = await box.session(sessionId).status();
|
|
45
|
+
if (!info || info.id !== sessionId)
|
|
46
|
+
throw new AgentFailure('agent_session_missing_restore_required', 409);
|
|
47
|
+
}
|
|
48
|
+
async function retained(box, enrollment, turnId) {
|
|
49
|
+
if (box.status !== 'running')
|
|
50
|
+
throw new AgentFailure('agent_environment_sleeping_resume_required', 409);
|
|
51
|
+
const session = box.session(enrollment.sessionId);
|
|
52
|
+
const admission = await session.admission(turnId);
|
|
53
|
+
if (!admission)
|
|
54
|
+
throw new AgentFailure('native_turn_not_admitted', 404);
|
|
55
|
+
if (admission.sessionId !== enrollment.sessionId || admission.turnId !== turnId || !admission.executionId)
|
|
56
|
+
throw new AgentFailure('native_admission_invalid', 502);
|
|
57
|
+
const base = { workspaceId: enrollment.workspaceId, threadId: enrollment.threadId,
|
|
58
|
+
turnId: admission.turnId, executionId: admission.executionId,
|
|
59
|
+
environment: environment(enrollment, box), source: 'sandbox-session-admission' };
|
|
60
|
+
if (admission.status === 'unavailable')
|
|
61
|
+
return { ...base, state: 'unknown' };
|
|
62
|
+
const runs = await session.runs();
|
|
63
|
+
const matches = runs.filter(run => run.executionId === admission.executionId);
|
|
64
|
+
if (matches.length > 1 || (matches[0] && matches[0].sessionId !== enrollment.sessionId))
|
|
65
|
+
throw new AgentFailure('native_execution_invalid', 502);
|
|
66
|
+
const run = matches[0];
|
|
67
|
+
// Expired/missing replay does not mean failure, completion, or permission to rerun.
|
|
68
|
+
if (!run)
|
|
69
|
+
return { ...base, state: 'unknown' };
|
|
70
|
+
if (run.status === 'active') {
|
|
71
|
+
const requests = await session.interactions();
|
|
72
|
+
return { ...base, state: requests.some(request => request.binding.executionId === admission.executionId) ? 'input-required' : 'working' };
|
|
73
|
+
}
|
|
74
|
+
if (run.status === 'failed' || run.status === 'cancelled')
|
|
75
|
+
return { ...base, state: run.status };
|
|
76
|
+
if (run.status !== 'completed')
|
|
77
|
+
throw new AgentFailure('native_execution_invalid', 502);
|
|
78
|
+
return { ...base, state: 'succeeded', response: true,
|
|
79
|
+
...(Number.isFinite(run.completedAt) ? { completedAt: new Date(run.completedAt).toISOString() } : {}) };
|
|
80
|
+
}
|
|
81
|
+
const services = {
|
|
82
|
+
list: context.list,
|
|
83
|
+
async connect(target) {
|
|
84
|
+
const { box, enrollment } = await locate(target, 'connect');
|
|
85
|
+
// A read does not wake or recreate the environment. The enrollment is retained
|
|
86
|
+
// while asleep; a prompt may resume that exact sandbox under its run grant.
|
|
87
|
+
if (box.status === 'running')
|
|
88
|
+
await requireSession(box, enrollment.sessionId);
|
|
89
|
+
return { agentRef: enrollment.agentRef, environment: environment(enrollment, box) };
|
|
90
|
+
},
|
|
91
|
+
async startTask(target, turnId, prompt, options) {
|
|
92
|
+
const { box, enrollment } = await locate(target, 'delegate');
|
|
93
|
+
if (['stopped', 'suspended'].includes(box.status)) {
|
|
94
|
+
await context.authorize(target, 'delegate');
|
|
95
|
+
await box.resume(); // Exact existing box. Never replace it on resume failure.
|
|
96
|
+
}
|
|
97
|
+
if (box.filesystemIncarnationId !== enrollment.filesystemIncarnationId)
|
|
98
|
+
throw new AgentFailure('agent_filesystem_changed_reconnect_required', 409);
|
|
99
|
+
await requireSession(box, enrollment.sessionId);
|
|
100
|
+
await context.authorize(target, 'delegate');
|
|
101
|
+
if (options?.contextVersion && options.contextVersion !== (await describeConnection({ agentRef: enrollment.agentRef, environment: environment(enrollment, box) })).contextVersion)
|
|
102
|
+
throw new AgentFailure('agent_context_changed_reconnect_required', 409);
|
|
103
|
+
// A named instance or host enrollment can move during the awaited session
|
|
104
|
+
// checks. The host must serialize migration with dispatch after this check.
|
|
105
|
+
const current = await context.resolve(target);
|
|
106
|
+
if (!current || current.workspaceId !== enrollment.workspaceId || current.threadId !== enrollment.threadId
|
|
107
|
+
|| current.sandboxId !== enrollment.sandboxId || current.sessionId !== enrollment.sessionId
|
|
108
|
+
|| current.agentRef !== enrollment.agentRef || current.filesystemIncarnationId !== enrollment.filesystemIncarnationId
|
|
109
|
+
|| current.profileVersion !== enrollment.profileVersion || current.instance?.key !== enrollment.instance?.key
|
|
110
|
+
|| current.instance?.generation !== enrollment.instance?.generation)
|
|
111
|
+
throw new AgentFailure('agent_environment_changed_reconnect_required', 409);
|
|
112
|
+
if (enrollment.instance) {
|
|
113
|
+
const instance = await context.client.instances.get(enrollment.instance.key);
|
|
114
|
+
if (!instance || instance.sandboxId !== enrollment.sandboxId || instance.generation !== enrollment.instance.generation
|
|
115
|
+
|| instance.profileVersion !== enrollment.profileVersion)
|
|
116
|
+
throw new AgentFailure('agent_environment_changed_reconnect_required', 409);
|
|
117
|
+
}
|
|
118
|
+
await context.authorize(target, 'delegate');
|
|
119
|
+
// Use the maintained message lane directly: the native route owns input-bound
|
|
120
|
+
// replay and serialized admission. Unlike driveConversationTurn, this cannot
|
|
121
|
+
// create a missing session or read a cached answer under only a run grant.
|
|
122
|
+
const receipt = await box.session(enrollment.sessionId).sendMessage({
|
|
123
|
+
turnId, parts: [{ type: 'text', text: prompt }],
|
|
124
|
+
});
|
|
125
|
+
if (!receipt.info?.id || receipt.processing !== true)
|
|
126
|
+
throw new AgentFailure('native_admission_invalid', 502);
|
|
127
|
+
const task = await retained(box, enrollment, turnId);
|
|
128
|
+
if (task.executionId !== receipt.info.id)
|
|
129
|
+
throw new AgentFailure('native_execution_changed', 502);
|
|
130
|
+
return task;
|
|
131
|
+
},
|
|
132
|
+
async getTask(target, turnId) {
|
|
133
|
+
const { box, enrollment } = await locate(target, 'task');
|
|
134
|
+
// Read-only admission lookup. Never call driveConversationTurn with a dummy
|
|
135
|
+
// prompt: that path validates input and may admit work when misconfigured.
|
|
136
|
+
return retained(box, enrollment, turnId);
|
|
137
|
+
},
|
|
138
|
+
async readResponse(target, turnId) {
|
|
139
|
+
const { box, enrollment } = await locate(target, 'output');
|
|
140
|
+
const task = await retained(box, enrollment, turnId);
|
|
141
|
+
if (task.state !== 'succeeded' || !task.executionId || !task.completedAt)
|
|
142
|
+
return null;
|
|
143
|
+
const cached = await box.findCompletedTurn(turnId, { sessionId: enrollment.sessionId });
|
|
144
|
+
if (!cached)
|
|
145
|
+
return null;
|
|
146
|
+
if (cached.turnId !== turnId || cached.sessionId !== enrollment.sessionId
|
|
147
|
+
|| cached.result.executionId !== task.executionId || !Number.isFinite(Date.parse(cached.completedAt)))
|
|
148
|
+
throw new AgentFailure('native_response_invalid', 502);
|
|
149
|
+
const content = cached.result.text ?? cached.result.response;
|
|
150
|
+
if (typeof content !== 'string')
|
|
151
|
+
throw new AgentFailure('native_response_invalid', 502);
|
|
152
|
+
const { deriveAgentResultOutcome } = await import('@tangle-network/sandbox');
|
|
153
|
+
const outcome = deriveAgentResultOutcome(cached.result);
|
|
154
|
+
await context.authorize(target, 'output');
|
|
155
|
+
return { workspaceId: enrollment.workspaceId, threadId: enrollment.threadId,
|
|
156
|
+
turnId: task.turnId, executionId: task.executionId, completedAt: task.completedAt,
|
|
157
|
+
content, mediaType: 'text/plain; charset=utf-8',
|
|
158
|
+
outcome: outcome.success ? 'completed' : outcome.status === 'failed' ? 'failed' : 'input-required' };
|
|
159
|
+
},
|
|
160
|
+
async cancelTask(target, turnId, executionId) {
|
|
161
|
+
const { box, enrollment } = await locate(target, 'cancel');
|
|
162
|
+
const before = await retained(box, enrollment, turnId);
|
|
163
|
+
if (before.executionId !== executionId)
|
|
164
|
+
throw new AgentFailure('native_execution_changed', 409);
|
|
165
|
+
if (['succeeded', 'failed', 'cancelled'].includes(before.state))
|
|
166
|
+
return before;
|
|
167
|
+
if (before.state === 'unknown')
|
|
168
|
+
throw new AgentFailure('native_execution_unavailable', 409);
|
|
169
|
+
await context.authorize(target, 'cancel');
|
|
170
|
+
await box.session(enrollment.sessionId).interrupt({ executionId });
|
|
171
|
+
// Acknowledgement alone is not proof that cancellation completed.
|
|
172
|
+
return retained(box, enrollment, turnId);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
return { platformUserId: context.platformUserId, applicationUserId: context.applicationUserId,
|
|
176
|
+
appName: context.appName, ...(context.name ? { name: context.name } : {}), requiredScopes: context.requiredScopes,
|
|
177
|
+
authorize: context.authorize, services };
|
|
178
|
+
}
|