nexus-agentd 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -22
- package/README.md +224 -446
- package/dist/a2a/runtime.d.ts +5 -4
- package/dist/a2a/runtime.js +83 -13
- package/dist/acp/runtime.d.ts +3 -8
- package/dist/acp/runtime.js +63 -79
- package/dist/completion-contract.d.ts +1 -0
- package/dist/completion-contract.js +9 -0
- package/dist/config.js +1 -15
- package/dist/control-plane.d.ts +0 -1
- package/dist/control-plane.js +1 -19
- package/dist/run-store.d.ts +1 -1
- package/dist/server.js +63 -248
- package/dist/session-contract.d.ts +3 -2
- package/dist/session.d.ts +11 -2
- package/dist/session.js +202 -17
- package/dist/types.d.ts +25 -45
- package/dist/webui/app.js +912 -1074
- package/dist/webui/icons.d.ts +0 -1
- package/dist/webui/icons.js +0 -1
- package/dist/webui/index.js +10 -10
- package/dist/webui/markup.js +65 -66
- package/dist/webui/styles.js +256 -268
- package/nexus-agentd.example.json +40 -47
- package/package.json +52 -52
- package/dist/agent-instructions.d.ts +0 -11
- package/dist/agent-instructions.js +0 -44
- package/dist/artifact-store.d.ts +0 -41
- package/dist/artifact-store.js +0 -340
- package/dist/workspace-files.d.ts +0 -48
- package/dist/workspace-files.js +0 -269
|
@@ -1,47 +1,40 @@
|
|
|
1
|
-
{
|
|
2
|
-
"initialized": false,
|
|
3
|
-
"listen": {
|
|
4
|
-
"host": "0.0.0.0",
|
|
5
|
-
"port": 8787
|
|
6
|
-
},
|
|
7
|
-
"workspaceRoots": [
|
|
8
|
-
"/data/repos"
|
|
9
|
-
],
|
|
10
|
-
"apiKeys": [],
|
|
11
|
-
"maxRequestBytes": 1048576,
|
|
12
|
-
"maxAttachmentBytes": 33554432,
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"auth": {
|
|
42
|
-
"type": "none"
|
|
43
|
-
},
|
|
44
|
-
"timeoutMs": 60000
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"initialized": false,
|
|
3
|
+
"listen": {
|
|
4
|
+
"host": "0.0.0.0",
|
|
5
|
+
"port": 8787
|
|
6
|
+
},
|
|
7
|
+
"workspaceRoots": [
|
|
8
|
+
"/data/repos"
|
|
9
|
+
],
|
|
10
|
+
"apiKeys": [],
|
|
11
|
+
"maxRequestBytes": 1048576,
|
|
12
|
+
"maxAttachmentBytes": 33554432,
|
|
13
|
+
"requestTimeoutMs": 30000,
|
|
14
|
+
"promptTimeoutMs": 1800000,
|
|
15
|
+
"cleanupIntervalMs": 60000,
|
|
16
|
+
"maxSessions": 64,
|
|
17
|
+
"maxSseConnections": 128,
|
|
18
|
+
"maxConnections": 256,
|
|
19
|
+
"sessionTtlMs": 86400000,
|
|
20
|
+
"agents": {
|
|
21
|
+
"codex": {
|
|
22
|
+
"protocol": "acp",
|
|
23
|
+
"driver": "codex",
|
|
24
|
+
"name": "Codex",
|
|
25
|
+
"command": "codex-acp",
|
|
26
|
+
"workspace": "/data/repos/project",
|
|
27
|
+
"permissionPolicy": "ask"
|
|
28
|
+
},
|
|
29
|
+
"research": {
|
|
30
|
+
"protocol": "a2a",
|
|
31
|
+
"name": "Research Agent",
|
|
32
|
+
"agentCardUrl": "http://192.168.1.20:8080/.well-known/agent-card.json",
|
|
33
|
+
"preferredTransport": "auto",
|
|
34
|
+
"auth": {
|
|
35
|
+
"type": "none"
|
|
36
|
+
},
|
|
37
|
+
"timeoutMs": 60000
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "nexus-agentd",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local Nexus Gateway daemon that exposes allowlisted Coding Agents through HTTP/SSE and ACP",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "dist/index.js",
|
|
7
|
-
"types": "dist/index.d.ts",
|
|
8
|
-
"bin": {
|
|
9
|
-
"nexus-agentd": "dist/cli.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"dist",
|
|
13
|
-
"nexus-agentd.example.json",
|
|
14
|
-
"README.md"
|
|
15
|
-
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=20.0.0"
|
|
18
|
-
},
|
|
19
|
-
"author": "lumia1998",
|
|
20
|
-
"repository": {
|
|
21
|
-
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/lumia1998/nexus-gateway.git"
|
|
23
|
-
},
|
|
24
|
-
"bugs": {
|
|
25
|
-
"url": "https://github.com/lumia1998/nexus-gateway/issues"
|
|
26
|
-
},
|
|
27
|
-
"homepage": "https://github.com/lumia1998/nexus-gateway#readme",
|
|
28
|
-
"keywords": [
|
|
29
|
-
"agent-client-protocol",
|
|
30
|
-
"acp",
|
|
31
|
-
"agent",
|
|
32
|
-
"gateway",
|
|
33
|
-
"coding-agent"
|
|
34
|
-
],
|
|
35
|
-
"scripts": {
|
|
36
|
-
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.json",
|
|
37
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
-
"test": "tsx --test test/*.test.ts",
|
|
39
|
-
"prepack": "npm run build"
|
|
40
|
-
},
|
|
41
|
-
"dependencies": {
|
|
42
|
-
"@a2a-js/sdk": "^1.0.1",
|
|
43
|
-
"@agentclientprotocol/sdk": "1.3.0",
|
|
44
|
-
"zod": "^3.25.76"
|
|
45
|
-
},
|
|
46
|
-
"devDependencies": {
|
|
47
|
-
"@types/node": "^20.14.0",
|
|
48
|
-
"tsx": "^4.21.0",
|
|
49
|
-
"typescript": "^5.6.0"
|
|
50
|
-
},
|
|
51
|
-
"license": "MIT"
|
|
52
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "nexus-agentd",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local Nexus Gateway daemon that exposes allowlisted Coding Agents through HTTP/SSE and ACP",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"nexus-agentd": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"nexus-agentd.example.json",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
|
+
},
|
|
19
|
+
"author": "lumia1998",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/lumia1998/nexus-gateway.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/lumia1998/nexus-gateway/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/lumia1998/nexus-gateway#readme",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"agent-client-protocol",
|
|
30
|
+
"acp",
|
|
31
|
+
"agent",
|
|
32
|
+
"gateway",
|
|
33
|
+
"coding-agent"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.json",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"test": "tsx --test test/*.test.ts",
|
|
39
|
+
"prepack": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@a2a-js/sdk": "^1.0.1",
|
|
43
|
+
"@agentclientprotocol/sdk": "1.3.0",
|
|
44
|
+
"zod": "^3.25.76"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^20.14.0",
|
|
48
|
+
"tsx": "^4.21.0",
|
|
49
|
+
"typescript": "^5.6.0"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export declare const MAX_AGENT_INSTRUCTIONS_CHARS: number;
|
|
2
|
-
/**
|
|
3
|
-
* Baseline guidance for Agents running behind Agent Nexus.
|
|
4
|
-
*
|
|
5
|
-
* This is delivered as the first prompt because ACP and A2A do not expose a
|
|
6
|
-
* portable system-message field across all supported Agent implementations.
|
|
7
|
-
*/
|
|
8
|
-
export declare const DEFAULT_AGENT_INSTRUCTIONS: string;
|
|
9
|
-
export declare function normalizeAgentInstructions(value: unknown, field?: string): string | undefined;
|
|
10
|
-
export declare function buildAgentInstructions(custom?: string): string;
|
|
11
|
-
export declare function composeInitialAgentPrompt(instructions: string, userMessage: string): string;
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
export const MAX_AGENT_INSTRUCTIONS_CHARS = 32 * 1024;
|
|
2
|
-
/**
|
|
3
|
-
* Baseline guidance for Agents running behind Agent Nexus.
|
|
4
|
-
*
|
|
5
|
-
* This is delivered as the first prompt because ACP and A2A do not expose a
|
|
6
|
-
* portable system-message field across all supported Agent implementations.
|
|
7
|
-
*/
|
|
8
|
-
export const DEFAULT_AGENT_INSTRUCTIONS = [
|
|
9
|
-
'You are running behind the Agent Nexus conversational host.',
|
|
10
|
-
'When an external tool or operation needs the user to choose, confirm, authorize, pay, or provide more information before it can continue:',
|
|
11
|
-
'1. Do not present the task as completed.',
|
|
12
|
-
'2. Use the host\'s native user-input, elicitation, or permission request mechanism when available; do not only print a normal-text question and stop.',
|
|
13
|
-
'3. Include a concise prompt and structured options when the mechanism supports them. For payment, include the payment URL and stable order metadata.',
|
|
14
|
-
'4. After the user replies, validate the result with the upstream tool. Never treat a message such as "payment completed" as proof that payment succeeded.',
|
|
15
|
-
'Only finish the task after the external operation has actually completed.'
|
|
16
|
-
].join('\n');
|
|
17
|
-
export function normalizeAgentInstructions(value, field = 'instructions') {
|
|
18
|
-
if (value === undefined || value === null)
|
|
19
|
-
return undefined;
|
|
20
|
-
if (typeof value !== 'string')
|
|
21
|
-
throw new Error(`${field} must be a string`);
|
|
22
|
-
const text = value.trim();
|
|
23
|
-
if (text.length > MAX_AGENT_INSTRUCTIONS_CHARS) {
|
|
24
|
-
throw new Error(`${field} must be at most ${MAX_AGENT_INSTRUCTIONS_CHARS} characters`);
|
|
25
|
-
}
|
|
26
|
-
return text || undefined;
|
|
27
|
-
}
|
|
28
|
-
export function buildAgentInstructions(custom) {
|
|
29
|
-
const extra = normalizeAgentInstructions(custom);
|
|
30
|
-
return extra
|
|
31
|
-
? `${DEFAULT_AGENT_INSTRUCTIONS}\n\nAdditional agent-specific instructions:\n${extra}`
|
|
32
|
-
: DEFAULT_AGENT_INSTRUCTIONS;
|
|
33
|
-
}
|
|
34
|
-
export function composeInitialAgentPrompt(instructions, userMessage) {
|
|
35
|
-
return [
|
|
36
|
-
'<agent-nexus-host-instructions>',
|
|
37
|
-
instructions,
|
|
38
|
-
'</agent-nexus-host-instructions>',
|
|
39
|
-
'',
|
|
40
|
-
'<user-request>',
|
|
41
|
-
userMessage,
|
|
42
|
-
'</user-request>'
|
|
43
|
-
].join('\n');
|
|
44
|
-
}
|
package/dist/artifact-store.d.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { type FileHandle } from 'node:fs/promises';
|
|
2
|
-
import { type Readable } from 'node:stream';
|
|
3
|
-
import type { AgentdPublishedFile } from './types.js';
|
|
4
|
-
interface StoredArtifact extends AgentdPublishedFile {
|
|
5
|
-
filePath: string;
|
|
6
|
-
handle?: FileHandle;
|
|
7
|
-
}
|
|
8
|
-
export declare class ArtifactStoreError extends Error {
|
|
9
|
-
readonly status: number;
|
|
10
|
-
constructor(status: number, message: string);
|
|
11
|
-
}
|
|
12
|
-
export declare class GatewayArtifactStore {
|
|
13
|
-
private readonly directory;
|
|
14
|
-
private readonly ttlMs;
|
|
15
|
-
private readonly maxBytes;
|
|
16
|
-
private readonly maxStorageBytes;
|
|
17
|
-
private readonly maxFiles;
|
|
18
|
-
private readonly maxConcurrentPublishes;
|
|
19
|
-
private readonly byCacheKey;
|
|
20
|
-
private cleanupTimer?;
|
|
21
|
-
private capacityQueue;
|
|
22
|
-
private activePublishes;
|
|
23
|
-
private reservedBytes;
|
|
24
|
-
private reservedFiles;
|
|
25
|
-
constructor(directory: string, ttlMs: number, maxBytes: number, maxStorageBytes?: number, maxFiles?: number, maxConcurrentPublishes?: number);
|
|
26
|
-
startCleanup(intervalMs?: number): void;
|
|
27
|
-
stopCleanup(): void;
|
|
28
|
-
publishFile(filePath: string, preferredName?: string, mediaType?: string): Promise<AgentdPublishedFile>;
|
|
29
|
-
publishHandle(handle: FileHandle, preferredName: string, mediaType?: string): Promise<AgentdPublishedFile>;
|
|
30
|
-
publishBytes(bytes: Uint8Array, preferredName: string, mediaType?: string, cacheKey?: string): Promise<AgentdPublishedFile>;
|
|
31
|
-
publishStream(input: Readable, preferredName: string, mediaType?: string, declaredSize?: number, cacheKey?: string): Promise<AgentdPublishedFile>;
|
|
32
|
-
open(id: string, expiresInput: string, requestedName: string): Promise<StoredArtifact>;
|
|
33
|
-
cleanup(): Promise<void>;
|
|
34
|
-
private ensureDirectory;
|
|
35
|
-
private isAvailable;
|
|
36
|
-
private reserveCapacity;
|
|
37
|
-
private storageUsage;
|
|
38
|
-
}
|
|
39
|
-
export declare function contentDisposition(name: string): string;
|
|
40
|
-
export declare function mediaTypeForName(name: string): string;
|
|
41
|
-
export {};
|
package/dist/artifact-store.js
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { createWriteStream } from 'node:fs';
|
|
3
|
-
import { chmod, mkdir, open, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { Transform } from 'node:stream';
|
|
6
|
-
import { pipeline } from 'node:stream/promises';
|
|
7
|
-
export class ArtifactStoreError extends Error {
|
|
8
|
-
status;
|
|
9
|
-
constructor(status, message) {
|
|
10
|
-
super(message);
|
|
11
|
-
this.status = status;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
export class GatewayArtifactStore {
|
|
15
|
-
directory;
|
|
16
|
-
ttlMs;
|
|
17
|
-
maxBytes;
|
|
18
|
-
maxStorageBytes;
|
|
19
|
-
maxFiles;
|
|
20
|
-
maxConcurrentPublishes;
|
|
21
|
-
byCacheKey = new Map();
|
|
22
|
-
cleanupTimer;
|
|
23
|
-
capacityQueue = Promise.resolve();
|
|
24
|
-
activePublishes = 0;
|
|
25
|
-
reservedBytes = 0;
|
|
26
|
-
reservedFiles = 0;
|
|
27
|
-
constructor(directory, ttlMs, maxBytes, maxStorageBytes = Math.max(maxBytes, 4 * 1024 * 1024 * 1024), maxFiles = 4096, maxConcurrentPublishes = 4) {
|
|
28
|
-
this.directory = directory;
|
|
29
|
-
this.ttlMs = ttlMs;
|
|
30
|
-
this.maxBytes = maxBytes;
|
|
31
|
-
this.maxStorageBytes = maxStorageBytes;
|
|
32
|
-
this.maxFiles = maxFiles;
|
|
33
|
-
this.maxConcurrentPublishes = maxConcurrentPublishes;
|
|
34
|
-
}
|
|
35
|
-
startCleanup(intervalMs = Math.min(this.ttlMs, 60 * 60 * 1000)) {
|
|
36
|
-
if (this.cleanupTimer)
|
|
37
|
-
return;
|
|
38
|
-
void this.cleanup().catch((error) => {
|
|
39
|
-
console.error(`[nexus-agentd] initial artifact cleanup failed: ${errorMessage(error)}`);
|
|
40
|
-
});
|
|
41
|
-
this.cleanupTimer = setInterval(() => {
|
|
42
|
-
void this.cleanup().catch((error) => {
|
|
43
|
-
console.error(`[nexus-agentd] artifact cleanup failed: ${errorMessage(error)}`);
|
|
44
|
-
});
|
|
45
|
-
}, Math.max(60_000, intervalMs));
|
|
46
|
-
this.cleanupTimer.unref?.();
|
|
47
|
-
}
|
|
48
|
-
stopCleanup() {
|
|
49
|
-
if (this.cleanupTimer)
|
|
50
|
-
clearInterval(this.cleanupTimer);
|
|
51
|
-
this.cleanupTimer = undefined;
|
|
52
|
-
}
|
|
53
|
-
async publishFile(filePath, preferredName, mediaType) {
|
|
54
|
-
const handle = await open(filePath, 'r');
|
|
55
|
-
try {
|
|
56
|
-
return await this.publishHandle(handle, preferredName || path.basename(filePath), mediaType);
|
|
57
|
-
}
|
|
58
|
-
finally {
|
|
59
|
-
await handle.close();
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
async publishHandle(handle, preferredName, mediaType) {
|
|
63
|
-
const details = await handle.stat();
|
|
64
|
-
if (!details.isFile())
|
|
65
|
-
throw new ArtifactStoreError(400, 'Only regular files can be published');
|
|
66
|
-
if (details.size > this.maxBytes)
|
|
67
|
-
throw new ArtifactStoreError(413, 'File exceeds the publish size limit');
|
|
68
|
-
return this.publishStream(handle.createReadStream({ autoClose: false }), preferredName, mediaType, details.size);
|
|
69
|
-
}
|
|
70
|
-
async publishBytes(bytes, preferredName, mediaType, cacheKey) {
|
|
71
|
-
if (bytes.byteLength > this.maxBytes) {
|
|
72
|
-
throw new ArtifactStoreError(413, 'File exceeds the publish size limit');
|
|
73
|
-
}
|
|
74
|
-
const { Readable } = await import('node:stream');
|
|
75
|
-
return this.publishStream(Readable.from([bytes]), preferredName, mediaType, bytes.byteLength, cacheKey);
|
|
76
|
-
}
|
|
77
|
-
async publishStream(input, preferredName, mediaType, declaredSize, cacheKey) {
|
|
78
|
-
if (declaredSize !== undefined && declaredSize > this.maxBytes) {
|
|
79
|
-
throw new ArtifactStoreError(413, 'File exceeds the publish size limit');
|
|
80
|
-
}
|
|
81
|
-
await this.ensureDirectory();
|
|
82
|
-
const releaseCapacity = await this.reserveCapacity(declaredSize ?? this.maxBytes);
|
|
83
|
-
const name = safeFilename(preferredName);
|
|
84
|
-
const temporary = path.join(this.directory, `.upload-${randomUUID()}`);
|
|
85
|
-
const hash = createHash('sha256');
|
|
86
|
-
let size = 0;
|
|
87
|
-
const meter = new Transform({
|
|
88
|
-
transform: (chunk, _encoding, callback) => {
|
|
89
|
-
size += chunk.length;
|
|
90
|
-
if (size > this.maxBytes) {
|
|
91
|
-
callback(new ArtifactStoreError(413, 'File exceeds the publish size limit'));
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
hash.update(chunk);
|
|
95
|
-
callback(null, chunk);
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
try {
|
|
99
|
-
await pipeline(input, meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 }));
|
|
100
|
-
const sha256 = hash.digest('hex');
|
|
101
|
-
const cacheId = cacheKey ? `${cacheKey}\0${sha256}` : undefined;
|
|
102
|
-
const existing = cacheId ? this.byCacheKey.get(cacheId) : undefined;
|
|
103
|
-
if (existing && (await this.isAvailable(existing))) {
|
|
104
|
-
await rm(temporary, { force: true });
|
|
105
|
-
return publicView(existing);
|
|
106
|
-
}
|
|
107
|
-
const id = randomBytes(32).toString('hex');
|
|
108
|
-
const expiresAt = Date.now() + this.ttlMs;
|
|
109
|
-
const filePath = path.join(this.directory, storedFilename(id, expiresAt, name));
|
|
110
|
-
await rename(temporary, filePath);
|
|
111
|
-
const stored = {
|
|
112
|
-
id,
|
|
113
|
-
name,
|
|
114
|
-
url: artifactUrl(id, expiresAt, name),
|
|
115
|
-
size,
|
|
116
|
-
mediaType: mediaType || mediaTypeForName(name),
|
|
117
|
-
sha256,
|
|
118
|
-
expiresAt,
|
|
119
|
-
filePath
|
|
120
|
-
};
|
|
121
|
-
if (cacheId)
|
|
122
|
-
this.byCacheKey.set(cacheId, stored);
|
|
123
|
-
return publicView(stored);
|
|
124
|
-
}
|
|
125
|
-
catch (error) {
|
|
126
|
-
await rm(temporary, { force: true }).catch(() => undefined);
|
|
127
|
-
throw error;
|
|
128
|
-
}
|
|
129
|
-
finally {
|
|
130
|
-
releaseCapacity();
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
async open(id, expiresInput, requestedName) {
|
|
134
|
-
if (!/^[a-f0-9]{64}$/.test(id))
|
|
135
|
-
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
136
|
-
const expiresAt = Number(expiresInput);
|
|
137
|
-
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) {
|
|
138
|
-
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
139
|
-
}
|
|
140
|
-
const name = safeFilename(requestedName);
|
|
141
|
-
if (name !== requestedName)
|
|
142
|
-
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
143
|
-
const filePath = path.join(this.directory, storedFilename(id, expiresAt, name));
|
|
144
|
-
let handle;
|
|
145
|
-
try {
|
|
146
|
-
handle = await open(filePath, 'r');
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
if (error.code === 'ENOENT') {
|
|
150
|
-
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
151
|
-
}
|
|
152
|
-
throw error;
|
|
153
|
-
}
|
|
154
|
-
let details;
|
|
155
|
-
try {
|
|
156
|
-
details = await handle.stat();
|
|
157
|
-
}
|
|
158
|
-
catch (error) {
|
|
159
|
-
await handle.close();
|
|
160
|
-
throw error;
|
|
161
|
-
}
|
|
162
|
-
if (!details.isFile()) {
|
|
163
|
-
await handle.close();
|
|
164
|
-
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
165
|
-
}
|
|
166
|
-
return {
|
|
167
|
-
id,
|
|
168
|
-
name,
|
|
169
|
-
url: artifactUrl(id, expiresAt, name),
|
|
170
|
-
size: details.size,
|
|
171
|
-
mediaType: mediaTypeForName(name),
|
|
172
|
-
sha256: '',
|
|
173
|
-
expiresAt,
|
|
174
|
-
filePath,
|
|
175
|
-
handle
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
async cleanup() {
|
|
179
|
-
await this.ensureDirectory();
|
|
180
|
-
const now = Date.now();
|
|
181
|
-
const names = await readdir(this.directory);
|
|
182
|
-
await Promise.all(names.map(async (name) => {
|
|
183
|
-
const filePath = path.join(this.directory, name);
|
|
184
|
-
try {
|
|
185
|
-
const parsed = parseStoredFilename(name);
|
|
186
|
-
if (parsed?.expiresAt !== undefined && parsed.expiresAt <= now) {
|
|
187
|
-
await rm(filePath, { force: true });
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (name.startsWith('.upload-')) {
|
|
191
|
-
const details = await stat(filePath);
|
|
192
|
-
if (details.mtimeMs + Math.max(this.ttlMs, 60 * 60 * 1000) <= now) {
|
|
193
|
-
await rm(filePath, { force: true });
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
catch (error) {
|
|
198
|
-
if (error.code !== 'ENOENT')
|
|
199
|
-
throw error;
|
|
200
|
-
}
|
|
201
|
-
}));
|
|
202
|
-
for (const [cacheId, artifact] of this.byCacheKey) {
|
|
203
|
-
if (artifact.expiresAt <= now)
|
|
204
|
-
this.byCacheKey.delete(cacheId);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
async ensureDirectory() {
|
|
208
|
-
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
209
|
-
if (process.platform !== 'win32')
|
|
210
|
-
await chmod(this.directory, 0o700);
|
|
211
|
-
}
|
|
212
|
-
async isAvailable(artifact) {
|
|
213
|
-
if (artifact.expiresAt <= Date.now())
|
|
214
|
-
return false;
|
|
215
|
-
try {
|
|
216
|
-
const details = await stat(artifact.filePath);
|
|
217
|
-
return details.isFile();
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
if (error.code === 'ENOENT')
|
|
221
|
-
return false;
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
async reserveCapacity(expectedBytes) {
|
|
226
|
-
let unlock;
|
|
227
|
-
const previous = this.capacityQueue;
|
|
228
|
-
this.capacityQueue = new Promise((resolve) => {
|
|
229
|
-
unlock = resolve;
|
|
230
|
-
});
|
|
231
|
-
await previous;
|
|
232
|
-
try {
|
|
233
|
-
if (this.activePublishes >= this.maxConcurrentPublishes) {
|
|
234
|
-
throw new ArtifactStoreError(429, 'Artifact publish concurrency limit reached');
|
|
235
|
-
}
|
|
236
|
-
const usage = await this.storageUsage();
|
|
237
|
-
if (usage.bytes + this.reservedBytes + expectedBytes > this.maxStorageBytes) {
|
|
238
|
-
throw new ArtifactStoreError(507, 'Artifact storage capacity reached');
|
|
239
|
-
}
|
|
240
|
-
if (usage.files + this.reservedFiles + 1 > this.maxFiles) {
|
|
241
|
-
throw new ArtifactStoreError(507, 'Artifact file count limit reached');
|
|
242
|
-
}
|
|
243
|
-
this.activePublishes += 1;
|
|
244
|
-
this.reservedBytes += expectedBytes;
|
|
245
|
-
this.reservedFiles += 1;
|
|
246
|
-
}
|
|
247
|
-
finally {
|
|
248
|
-
unlock();
|
|
249
|
-
}
|
|
250
|
-
let released = false;
|
|
251
|
-
return () => {
|
|
252
|
-
if (released)
|
|
253
|
-
return;
|
|
254
|
-
released = true;
|
|
255
|
-
this.activePublishes = Math.max(0, this.activePublishes - 1);
|
|
256
|
-
this.reservedBytes = Math.max(0, this.reservedBytes - expectedBytes);
|
|
257
|
-
this.reservedFiles = Math.max(0, this.reservedFiles - 1);
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
async storageUsage() {
|
|
261
|
-
await this.ensureDirectory();
|
|
262
|
-
const now = Date.now();
|
|
263
|
-
let bytes = 0;
|
|
264
|
-
let files = 0;
|
|
265
|
-
for (const name of await readdir(this.directory)) {
|
|
266
|
-
const parsed = parseStoredFilename(name);
|
|
267
|
-
if (parsed?.expiresAt !== undefined && parsed.expiresAt <= now) {
|
|
268
|
-
await rm(path.join(this.directory, name), { force: true });
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
try {
|
|
272
|
-
const details = await stat(path.join(this.directory, name));
|
|
273
|
-
if (!details.isFile())
|
|
274
|
-
continue;
|
|
275
|
-
bytes += details.size;
|
|
276
|
-
files += 1;
|
|
277
|
-
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
if (error.code !== 'ENOENT')
|
|
280
|
-
throw error;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return { bytes, files };
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
export function contentDisposition(name) {
|
|
287
|
-
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
288
|
-
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
289
|
-
}
|
|
290
|
-
export function mediaTypeForName(name) {
|
|
291
|
-
const types = {
|
|
292
|
-
'.avif': 'image/avif',
|
|
293
|
-
'.csv': 'text/csv; charset=utf-8',
|
|
294
|
-
'.gif': 'image/gif',
|
|
295
|
-
'.html': 'text/html; charset=utf-8',
|
|
296
|
-
'.jpeg': 'image/jpeg',
|
|
297
|
-
'.jpg': 'image/jpeg',
|
|
298
|
-
'.json': 'application/json; charset=utf-8',
|
|
299
|
-
'.md': 'text/markdown; charset=utf-8',
|
|
300
|
-
'.mp3': 'audio/mpeg',
|
|
301
|
-
'.mp4': 'video/mp4',
|
|
302
|
-
'.ogg': 'audio/ogg',
|
|
303
|
-
'.pdf': 'application/pdf',
|
|
304
|
-
'.png': 'image/png',
|
|
305
|
-
'.svg': 'image/svg+xml',
|
|
306
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
307
|
-
'.wav': 'audio/wav',
|
|
308
|
-
'.webm': 'video/webm',
|
|
309
|
-
'.webp': 'image/webp',
|
|
310
|
-
'.zip': 'application/zip'
|
|
311
|
-
};
|
|
312
|
-
return types[path.extname(name).toLowerCase()] || 'application/octet-stream';
|
|
313
|
-
}
|
|
314
|
-
function artifactUrl(id, expiresAt, name) {
|
|
315
|
-
return `/v1/artifacts/${id}/${expiresAt}/${encodeURIComponent(name)}`;
|
|
316
|
-
}
|
|
317
|
-
function storedFilename(id, expiresAt, name) {
|
|
318
|
-
return `${id}-${expiresAt}-${name}`;
|
|
319
|
-
}
|
|
320
|
-
function parseStoredFilename(value) {
|
|
321
|
-
const match = /^([a-f0-9]{64})-(\d+)-(.+)$/.exec(value);
|
|
322
|
-
if (!match)
|
|
323
|
-
return undefined;
|
|
324
|
-
const expiresAt = Number(match[2]);
|
|
325
|
-
if (!Number.isSafeInteger(expiresAt))
|
|
326
|
-
return undefined;
|
|
327
|
-
return { id: match[1], expiresAt, name: match[3] };
|
|
328
|
-
}
|
|
329
|
-
function safeFilename(value) {
|
|
330
|
-
const basename = path.basename(String(value || 'artifact').replace(/\\/g, '/'));
|
|
331
|
-
const safe = basename.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').trim().slice(0, 180);
|
|
332
|
-
return safe || 'artifact';
|
|
333
|
-
}
|
|
334
|
-
function publicView(value) {
|
|
335
|
-
const { filePath: _filePath, handle: _handle, ...result } = value;
|
|
336
|
-
return result;
|
|
337
|
-
}
|
|
338
|
-
function errorMessage(error) {
|
|
339
|
-
return error instanceof Error ? error.message : String(error);
|
|
340
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { type FileHandle } from 'node:fs/promises';
|
|
2
|
-
import { type Readable } from 'node:stream';
|
|
3
|
-
import type { AgentdFileEntryView, AgentdFileRootView } from './types.js';
|
|
4
|
-
export declare class WorkspaceFileError extends Error {
|
|
5
|
-
readonly status: number;
|
|
6
|
-
constructor(status: number, message: string);
|
|
7
|
-
}
|
|
8
|
-
export declare class WorkspaceFiles {
|
|
9
|
-
private readonly configuredRoots;
|
|
10
|
-
private readonly maxWriteBytes;
|
|
11
|
-
constructor(configuredRoots: () => string[], maxWriteBytes: number);
|
|
12
|
-
roots(): Promise<AgentdFileRootView[]>;
|
|
13
|
-
adminRoot(id: string): Promise<string>;
|
|
14
|
-
sessionRoot(workspace?: string): Promise<string>;
|
|
15
|
-
list(root: string, relative?: string): Promise<{
|
|
16
|
-
path: string;
|
|
17
|
-
entries: AgentdFileEntryView[];
|
|
18
|
-
}>;
|
|
19
|
-
open(root: string, relative: string): Promise<{
|
|
20
|
-
handle: FileHandle;
|
|
21
|
-
filePath: string;
|
|
22
|
-
name: string;
|
|
23
|
-
size: number;
|
|
24
|
-
}>;
|
|
25
|
-
publishable(root: string, input: string): Promise<{
|
|
26
|
-
handle: FileHandle;
|
|
27
|
-
filePath: string;
|
|
28
|
-
name: string;
|
|
29
|
-
size: number;
|
|
30
|
-
}>;
|
|
31
|
-
write(root: string, relative: string, input: Readable, declaredSize?: number): Promise<{
|
|
32
|
-
path: string;
|
|
33
|
-
size: number;
|
|
34
|
-
}>;
|
|
35
|
-
createDirectory(root: string, relative: string): Promise<{
|
|
36
|
-
path: string;
|
|
37
|
-
}>;
|
|
38
|
-
move(root: string, source: string, destination: string): Promise<{
|
|
39
|
-
path: string;
|
|
40
|
-
destination: string;
|
|
41
|
-
}>;
|
|
42
|
-
remove(root: string, relative: string): Promise<{
|
|
43
|
-
path: string;
|
|
44
|
-
}>;
|
|
45
|
-
private existing;
|
|
46
|
-
private writable;
|
|
47
|
-
}
|
|
48
|
-
export declare function workspaceRootId(root: string): string;
|