@myagentroam/node 0.9.76 → 0.9.78
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/dist/connector.js +6 -2
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +51 -10
- package/dist/service/conversation-hosting-capability-service.js +29 -4
- package/dist/service/hosted-conversation-node-service.js +50 -17
- package/dist/service/hosted-conversation-sandbox-profile.js +57 -0
- package/package.json +3 -3
- package/dist/service/bubblewrap-sandbox-executor.js +0 -543
package/dist/connector.js
CHANGED
|
@@ -79,7 +79,8 @@ import { NodeUpgradeCoordinator, nodeUpgradeRequestPath } from './service/node-u
|
|
|
79
79
|
import { HostedConversationStore } from './service/hosted-conversation-store.js';
|
|
80
80
|
import { ConversationTemplateNodeService } from './service/conversation-template-node-service.js';
|
|
81
81
|
import { HostedConversationNodeService } from './service/hosted-conversation-node-service.js';
|
|
82
|
-
import { BubblewrapSandboxExecutor } from '
|
|
82
|
+
import { BubblewrapSandboxExecutor } from '@myagentroam/agent/sandbox/linux';
|
|
83
|
+
import { hostedConversationSandboxProfile } from './service/hosted-conversation-sandbox-profile.js';
|
|
83
84
|
import { MarAgentHostedConversationRuntime } from './runner/mar-agent/hosted-conversation-runtime.js';
|
|
84
85
|
import { conversationHostingCapability, probeBubblewrap } from './service/conversation-hosting-capability-service.js';
|
|
85
86
|
import { ConversationHostingMaintenanceService } from './service/conversation-hosting-maintenance-service.js';
|
|
@@ -103,6 +104,7 @@ const HOSTED_CONVERSATION_OPERATIONS = [
|
|
|
103
104
|
'conversation.hosted.list',
|
|
104
105
|
'conversation.hosted.create',
|
|
105
106
|
'conversation.hosted.get',
|
|
107
|
+
'conversation.hosted.history',
|
|
106
108
|
'conversation.hosted.watch',
|
|
107
109
|
'conversation.hosted.unwatch',
|
|
108
110
|
'conversation.hosted.message',
|
|
@@ -1310,7 +1312,9 @@ export class NodeConnector {
|
|
|
1310
1312
|
emitWorkbenchEvent: (event) => this.workbenchEventService.publish(event),
|
|
1311
1313
|
readImage: async (runId, imageIndex) => this.runAttachmentService.read(runId, imageIndex) ??
|
|
1312
1314
|
(await this.runAttachmentService.readNative(runId, imageIndex)),
|
|
1313
|
-
processExecutorFactory: (input) => new BubblewrapSandboxExecutor(
|
|
1315
|
+
processExecutorFactory: (input) => new BubblewrapSandboxExecutor({
|
|
1316
|
+
profile: hostedConversationSandboxProfile(input)
|
|
1317
|
+
}),
|
|
1314
1318
|
assertCanCreate: () => {
|
|
1315
1319
|
if (maintenanceService === undefined)
|
|
1316
1320
|
throw new Error('CONVERSATION_HOSTING_UNAVAILABLE');
|
|
@@ -55,25 +55,36 @@ export class MarAgentHostedConversationRuntime {
|
|
|
55
55
|
async history(input) {
|
|
56
56
|
const catalog = await this.#openCatalog({ homeDirectory: input.homeDirectory });
|
|
57
57
|
try {
|
|
58
|
-
|
|
58
|
+
const limit = Math.min(Math.max(input.limit ?? 10, 1), 100);
|
|
59
|
+
let cursor = input.cursor === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: decodeHostedHistoryCursor(input.cursor, input.sessionId);
|
|
59
62
|
let entries = [];
|
|
63
|
+
let turns = projectMarAgentHistory(historySession(input), entries);
|
|
60
64
|
do {
|
|
61
65
|
const page = await catalog.history(input.sessionId, {
|
|
62
|
-
limit: 1000,
|
|
66
|
+
limit: Math.min(1000, Math.max(100, limit * 20)),
|
|
63
67
|
...(cursor === undefined ? {} : { cursor })
|
|
64
68
|
});
|
|
65
69
|
entries = [...page.entries, ...entries];
|
|
66
70
|
cursor = page.nextCursor;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
turns = projectMarAgentHistory(historySession(input), entries);
|
|
72
|
+
} while (cursor !== undefined && turns.length <= limit);
|
|
73
|
+
const selected = turns.slice(-limit);
|
|
74
|
+
const earliestTurnId = selected[0]?.id;
|
|
75
|
+
const earliestSequence = earliestTurnId === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: entries.find((entry) => historyEntryTurnId(entry) === earliestTurnId)?.sequence;
|
|
78
|
+
const projected = projectMarAgentHistory(historySession(input), entries, {
|
|
74
79
|
registerInputImages: (entry) => this.#registerInputImages(input, entry),
|
|
75
80
|
registerGeneratedImages: (sessionId, images) => this.#registerGeneratedImages(input, sessionId, images)
|
|
76
|
-
});
|
|
81
|
+
}).slice(-limit);
|
|
82
|
+
return {
|
|
83
|
+
turns: projected,
|
|
84
|
+
nextCursor: turns.length > selected.length && earliestSequence !== undefined
|
|
85
|
+
? encodeHostedHistoryCursor(input.sessionId, earliestSequence)
|
|
86
|
+
: null
|
|
87
|
+
};
|
|
77
88
|
}
|
|
78
89
|
finally {
|
|
79
90
|
await catalog.close();
|
|
@@ -342,6 +353,36 @@ export class MarAgentHostedConversationRuntime {
|
|
|
342
353
|
}
|
|
343
354
|
}
|
|
344
355
|
}
|
|
356
|
+
function historySession(input) {
|
|
357
|
+
return {
|
|
358
|
+
id: input.sessionId,
|
|
359
|
+
model: input.modelId ?? null,
|
|
360
|
+
effort: null,
|
|
361
|
+
access: 'full-access'
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function historyEntryTurnId(entry) {
|
|
365
|
+
return entry.kind === 'user' ? entry.turnId : entry.event.turnId;
|
|
366
|
+
}
|
|
367
|
+
function encodeHostedHistoryCursor(sessionId, beforeSequence) {
|
|
368
|
+
return Buffer.from(JSON.stringify({ version: 1, sessionId, beforeSequence }), 'utf8').toString('base64url');
|
|
369
|
+
}
|
|
370
|
+
function decodeHostedHistoryCursor(cursor, sessionId) {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
373
|
+
if (parsed.version !== 1 ||
|
|
374
|
+
parsed.sessionId !== sessionId ||
|
|
375
|
+
!Number.isSafeInteger(parsed.beforeSequence) ||
|
|
376
|
+
parsed.beforeSequence <= 0)
|
|
377
|
+
throw new Error('HOSTED_CONVERSATION_HISTORY_CURSOR_INVALID');
|
|
378
|
+
return String(parsed.beforeSequence);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (error instanceof Error && error.message === 'HOSTED_CONVERSATION_HISTORY_CURSOR_INVALID')
|
|
382
|
+
throw error;
|
|
383
|
+
throw new Error('HOSTED_CONVERSATION_HISTORY_CURSOR_INVALID', { cause: error });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
345
386
|
function projectHostedEvent(normalizer, event, userMessage, registerGeneratedImages) {
|
|
346
387
|
const createdAt = Date.parse(event.timestamp);
|
|
347
388
|
const base = {
|
|
@@ -1,16 +1,41 @@
|
|
|
1
|
+
import { LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION, probeBubblewrapSandbox } from '@myagentroam/agent/sandbox/linux';
|
|
2
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
1
5
|
import { safeErrorCode } from '../util/safe-error.js';
|
|
2
|
-
import {
|
|
6
|
+
import { hostedConversationSandboxProfile } from './hosted-conversation-sandbox-profile.js';
|
|
3
7
|
const CONVERSATION_HOSTING_API_VERSION = 1;
|
|
4
|
-
const SANDBOX_POLICY_VERSION = 1;
|
|
5
8
|
/** Runs the same bounded Bubblewrap policy used by hosted local processes. */
|
|
6
9
|
export async function probeBubblewrap() {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'mar-node-bwrap-probe-')).catch(() => undefined);
|
|
11
|
+
if (root === undefined)
|
|
12
|
+
return { available: false, reasonCode: 'SANDBOX_UNAVAILABLE' };
|
|
13
|
+
const workspaceDirectory = join(root, 'workspace');
|
|
14
|
+
const inboxDirectory = join(root, 'inbox');
|
|
15
|
+
const outputDirectory = join(root, 'output');
|
|
16
|
+
const temporaryDirectory = join(root, 'tmp');
|
|
7
17
|
try {
|
|
8
|
-
|
|
18
|
+
await Promise.all([workspaceDirectory, inboxDirectory, outputDirectory, temporaryDirectory].map((path) => mkdir(path, { mode: 0o700 })));
|
|
19
|
+
await Promise.all(['inbox', 'output'].map((name) => mkdir(join(workspaceDirectory, name), { recursive: true, mode: 0o700 })));
|
|
20
|
+
const result = await probeBubblewrapSandbox({
|
|
21
|
+
profile: hostedConversationSandboxProfile({
|
|
22
|
+
workspaceDirectory,
|
|
23
|
+
sourceWorkspacePath: '/home/mar-sandbox-probe/workspace',
|
|
24
|
+
inboxDirectory,
|
|
25
|
+
outputDirectory,
|
|
26
|
+
temporaryDirectory,
|
|
27
|
+
networkMode: 'NONE'
|
|
28
|
+
}),
|
|
29
|
+
cwd: workspaceDirectory
|
|
30
|
+
});
|
|
9
31
|
return { available: true, version: result.version };
|
|
10
32
|
}
|
|
11
33
|
catch {
|
|
12
34
|
return { available: false, reasonCode: 'SANDBOX_UNAVAILABLE' };
|
|
13
35
|
}
|
|
36
|
+
finally {
|
|
37
|
+
await rm(root, { recursive: true, force: true }).catch(() => undefined);
|
|
38
|
+
}
|
|
14
39
|
}
|
|
15
40
|
export async function conversationHostingCapability(capabilities, config, storageReady, storageFailureCode, probe = probeBubblewrap, capacity = 'AVAILABLE') {
|
|
16
41
|
if (capabilities.platform !== 'linux' || config?.conversationHosting?.enabled !== true) {
|
|
@@ -34,7 +59,7 @@ export async function conversationHostingCapability(capabilities, config, storag
|
|
|
34
59
|
state: 'AVAILABLE',
|
|
35
60
|
apiVersion: CONVERSATION_HOSTING_API_VERSION,
|
|
36
61
|
sandboxBackend: 'BUBBLEWRAP',
|
|
37
|
-
sandboxPolicyVersion:
|
|
62
|
+
sandboxPolicyVersion: LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION,
|
|
38
63
|
acceptsNewConversations: capacity !== 'FULL',
|
|
39
64
|
supportsExistingConversations: true,
|
|
40
65
|
capacity
|
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { lstat, mkdir, open, readFile, rename, rm, utimes } from 'node:fs/promises';
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { conversationTurnSchema, conversationTemplateDraftSchema, conversationTemplateSummarySchema, hostedConversationConfigUpdateInputSchema, hostedConversationCreateInputSchema, hostedConversationDeleteResultSchema, hostedConversationFileDeleteInputSchema, hostedConversationFileDeleteResultSchema, hostedConversationFileReadInputSchema, hostedConversationFileReadResultSchema, hostedConversationFilesSchema, hostedConversationIdInputSchema, hostedConversationImageReadInputSchema, hostedConversationListInputSchema, hostedConversationListPageSchema, hostedConversationMessageInputSchema, hostedConversationMetadataUpdateInputSchema, hostedConversationRollbackInputSchema, hostedConversationSnapshotSchema, hostedConversationSummarySchema, hostedConversationUnwatchInputSchema, hostedConversationUploadChunkInputSchema, hostedConversationUploadControlInputSchema, hostedConversationUploadCreateInputSchema, hostedConversationUploadPreflightInputSchema, hostedConversationUploadPreflightResultSchema, hostedConversationUploadSchema, hostedConversationWatchInputSchema, marAgentRuntimeModelConfigurationSchema, mcpInstallationConfigurationSchema, mcpRuntimeDescriptorSchema, resolveMarAgentDefaultReasoningEffort } from '@myagentroam/protocol';
|
|
5
|
+
import { conversationTurnSchema, conversationTemplateDraftSchema, conversationTemplateSummarySchema, hostedConversationConfigUpdateInputSchema, hostedConversationCreateInputSchema, hostedConversationDeleteResultSchema, hostedConversationFileDeleteInputSchema, hostedConversationFileDeleteResultSchema, hostedConversationFileReadInputSchema, hostedConversationFileReadResultSchema, hostedConversationFilesSchema, hostedConversationHistoryInputSchema, hostedConversationHistoryPageSchema, hostedConversationIdInputSchema, hostedConversationImageReadInputSchema, hostedConversationListInputSchema, hostedConversationListPageSchema, hostedConversationMessageInputSchema, hostedConversationMetadataUpdateInputSchema, hostedConversationRollbackInputSchema, hostedConversationSnapshotSchema, hostedConversationSummarySchema, hostedConversationUnwatchInputSchema, hostedConversationUploadChunkInputSchema, hostedConversationUploadControlInputSchema, hostedConversationUploadCreateInputSchema, hostedConversationUploadPreflightInputSchema, hostedConversationUploadPreflightResultSchema, hostedConversationUploadSchema, hostedConversationWatchInputSchema, marAgentRuntimeModelConfigurationSchema, mcpInstallationConfigurationSchema, mcpRuntimeDescriptorSchema, resolveMarAgentDefaultReasoningEffort } from '@myagentroam/protocol';
|
|
6
6
|
import { hostedConversationGeneratedImagesDisplayDirectory, hostedConversationImageRunId, marAgentGeneratedImagesDirectory } from '../runner/mar-agent/generated-images.js';
|
|
7
7
|
import { sessionTitleFromFirstMessage } from '../util/node-operation-parsers.js';
|
|
8
8
|
import { safeErrorCode } from '../util/safe-error.js';
|
|
@@ -61,6 +61,7 @@ export class HostedConversationNodeService {
|
|
|
61
61
|
'conversation.hosted.list': (raw) => this.list(raw),
|
|
62
62
|
'conversation.hosted.create': (raw) => this.create(raw),
|
|
63
63
|
'conversation.hosted.get': (raw) => this.get(raw),
|
|
64
|
+
'conversation.hosted.history': (raw) => this.history(raw),
|
|
64
65
|
'conversation.hosted.watch': (raw) => this.watch(raw),
|
|
65
66
|
'conversation.hosted.unwatch': (raw) => this.unwatch(raw),
|
|
66
67
|
'conversation.hosted.message': (raw) => this.message(raw),
|
|
@@ -216,9 +217,24 @@ export class HostedConversationNodeService {
|
|
|
216
217
|
const input = hostedConversationIdInputSchema.parse(payload.data);
|
|
217
218
|
return this.snapshot(payload.userId, input.conversationId, runtimeModels(payload.models), payload.templateDeleted);
|
|
218
219
|
}
|
|
219
|
-
async
|
|
220
|
+
async history(raw) {
|
|
221
|
+
const payload = hostedRequest(raw);
|
|
222
|
+
const input = hostedConversationHistoryInputSchema.parse(payload.data);
|
|
223
|
+
const conversation = this.options.store.requireConversation(payload.userId, input.conversationId);
|
|
224
|
+
const paths = this.options.store.pathsForConversation(payload.userId, conversation.id);
|
|
225
|
+
const page = await this.options.runtime.history({
|
|
226
|
+
conversationId: conversation.id,
|
|
227
|
+
sessionId: conversation.sessionId,
|
|
228
|
+
homeDirectory: paths.homeDirectory,
|
|
229
|
+
modelId: conversation.modelId,
|
|
230
|
+
limit: input.limit,
|
|
231
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor })
|
|
232
|
+
});
|
|
233
|
+
return hostedConversationHistoryPageSchema.parse(page);
|
|
234
|
+
}
|
|
235
|
+
async snapshot(userId, conversationId, availableModels, templateDeleted = false, includeActiveHistoryMutation = false, historyOverride) {
|
|
220
236
|
if (includeActiveHistoryMutation)
|
|
221
|
-
return this.readSnapshot(userId, conversationId, availableModels, templateDeleted,
|
|
237
|
+
return this.readSnapshot(userId, conversationId, availableModels, templateDeleted, historyOverride);
|
|
222
238
|
while (true) {
|
|
223
239
|
const version = this.historyVersions.get(conversationId) ?? 0;
|
|
224
240
|
const mutation = this.historyMutations.get(conversationId);
|
|
@@ -232,7 +248,7 @@ export class HostedConversationNodeService {
|
|
|
232
248
|
return snapshot;
|
|
233
249
|
}
|
|
234
250
|
}
|
|
235
|
-
async readSnapshot(userId, conversationId, availableModels, templateDeleted,
|
|
251
|
+
async readSnapshot(userId, conversationId, availableModels, templateDeleted, historyOverride) {
|
|
236
252
|
let conversation = this.options.store.requireConversation(userId, conversationId);
|
|
237
253
|
if (templateDeleted)
|
|
238
254
|
conversation = await this.options.store.retainConversationTemplate(userId, conversationId, this.now());
|
|
@@ -243,19 +259,21 @@ export class HostedConversationNodeService {
|
|
|
243
259
|
const version = await this.loadTemplateVersion(displayVersionId, conversation.templateId);
|
|
244
260
|
const models = networkModels(allowedModels(version, availableModels), version.draft.networkMode);
|
|
245
261
|
const paths = this.options.store.pathsForConversation(userId, conversation.id);
|
|
246
|
-
const
|
|
262
|
+
const history = historyOverride ??
|
|
247
263
|
(await this.options.runtime.history({
|
|
248
264
|
conversationId: conversation.id,
|
|
249
265
|
sessionId: conversation.sessionId,
|
|
250
266
|
homeDirectory: paths.homeDirectory,
|
|
251
|
-
modelId: conversation.modelId
|
|
267
|
+
modelId: conversation.modelId,
|
|
268
|
+
limit: 10
|
|
252
269
|
}));
|
|
253
270
|
return hostedConversationSnapshotSchema.parse({
|
|
254
271
|
nodeAppVersion: this.options.nodeAppVersion?.() ?? 'unknown',
|
|
255
272
|
conversation: this.summary(conversation),
|
|
256
273
|
template: templateSummary(conversation, version.draft),
|
|
257
274
|
run: this.activeRuns.get(conversation.id)?.run ?? null,
|
|
258
|
-
turns,
|
|
275
|
+
turns: history.turns,
|
|
276
|
+
historyNextCursor: history.nextCursor,
|
|
259
277
|
files: this.options.store.listFiles(userId, conversation.id),
|
|
260
278
|
models: models.map(modelSummary),
|
|
261
279
|
error: null,
|
|
@@ -650,12 +668,7 @@ export class HostedConversationNodeService {
|
|
|
650
668
|
}
|
|
651
669
|
async rollbackConversation(payload, conversation, input) {
|
|
652
670
|
const paths = this.options.store.pathsForConversation(payload.userId, conversation.id);
|
|
653
|
-
const turns =
|
|
654
|
-
conversationId: conversation.id,
|
|
655
|
-
sessionId: conversation.sessionId,
|
|
656
|
-
homeDirectory: paths.homeDirectory,
|
|
657
|
-
modelId: conversation.modelId
|
|
658
|
-
})).map((turn) => conversationTurnSchema.parse(turn));
|
|
671
|
+
const turns = await this.readAllHistory(conversation, paths);
|
|
659
672
|
const targetIndex = turns.findIndex((turn) => turn.id === input.turnId);
|
|
660
673
|
const target = turns[targetIndex];
|
|
661
674
|
const userMessage = target?.items.find((item) => item.kind === 'user_message' && item.status === 'COMPLETED');
|
|
@@ -681,10 +694,27 @@ export class HostedConversationNodeService {
|
|
|
681
694
|
}
|
|
682
695
|
this.historyVersions.set(conversation.id, (this.historyVersions.get(conversation.id) ?? 0) + 1);
|
|
683
696
|
await this.options.store.advanceLastActivity(conversation.id, Math.max(this.now(), rolledBack.lastActivityAt));
|
|
684
|
-
const snapshot = await this.snapshot(payload.userId, conversation.id, runtimeModels(payload.models), payload.templateDeleted, true
|
|
697
|
+
const snapshot = await this.snapshot(payload.userId, conversation.id, runtimeModels(payload.models), payload.templateDeleted, true);
|
|
685
698
|
this.publishHostedSnapshot(conversation.id, snapshot);
|
|
686
699
|
return snapshot;
|
|
687
700
|
}
|
|
701
|
+
async readAllHistory(conversation, paths) {
|
|
702
|
+
const turns = [];
|
|
703
|
+
let cursor;
|
|
704
|
+
do {
|
|
705
|
+
const page = await this.options.runtime.history({
|
|
706
|
+
conversationId: conversation.id,
|
|
707
|
+
sessionId: conversation.sessionId,
|
|
708
|
+
homeDirectory: paths.homeDirectory,
|
|
709
|
+
modelId: conversation.modelId,
|
|
710
|
+
limit: 100,
|
|
711
|
+
...(cursor === undefined ? {} : { cursor })
|
|
712
|
+
});
|
|
713
|
+
turns.unshift(...page.turns.map((turn) => conversationTurnSchema.parse(turn)));
|
|
714
|
+
cursor = page.nextCursor ?? undefined;
|
|
715
|
+
} while (cursor !== undefined);
|
|
716
|
+
return turns;
|
|
717
|
+
}
|
|
688
718
|
async finishRun(userId, conversationId, clientMessageId, run, completion) {
|
|
689
719
|
const interruptionKey = runInterruptionKey(conversationId, run.id);
|
|
690
720
|
const expectedInterruption = this.expectedInterruptions.has(interruptionKey);
|
|
@@ -701,7 +731,7 @@ export class HostedConversationNodeService {
|
|
|
701
731
|
completedAt: completion.completedAt
|
|
702
732
|
}
|
|
703
733
|
});
|
|
704
|
-
void this.refreshReadyWatchSnapshots(conversationId);
|
|
734
|
+
void this.refreshReadyWatchSnapshots(conversationId, run.id);
|
|
705
735
|
try {
|
|
706
736
|
const files = await this.options.store.reconcileFiles(userId, conversationId);
|
|
707
737
|
this.publishHostedEvent(conversationId, {
|
|
@@ -1178,12 +1208,16 @@ export class HostedConversationNodeService {
|
|
|
1178
1208
|
this.removeWatchLease(lease.ownerKey);
|
|
1179
1209
|
}
|
|
1180
1210
|
}
|
|
1181
|
-
async refreshReadyWatchSnapshots(conversationId) {
|
|
1211
|
+
async refreshReadyWatchSnapshots(conversationId, completedRunId) {
|
|
1182
1212
|
const leases = [...this.watchLeases.values()].filter((lease) => lease.conversationId === conversationId && lease.ready);
|
|
1183
1213
|
await Promise.allSettled(leases.map(async (lease) => {
|
|
1184
1214
|
const snapshot = await this.snapshot(lease.userId, conversationId, lease.models);
|
|
1185
1215
|
if (this.watchLeases.get(lease.ownerKey) !== lease || !lease.ready)
|
|
1186
1216
|
return;
|
|
1217
|
+
const activeRunId = this.activeRuns.get(conversationId)?.run.id;
|
|
1218
|
+
if ((activeRunId !== undefined && activeRunId !== completedRunId) ||
|
|
1219
|
+
(activeRunId === undefined && this.activity(conversationId).status !== 'IDLE'))
|
|
1220
|
+
return;
|
|
1187
1221
|
this.options.emitWatchEvent?.({
|
|
1188
1222
|
type: 'watch.hosted-conversation.snapshot',
|
|
1189
1223
|
workbenchConnectionId: lease.workbenchConnectionId,
|
|
@@ -1496,7 +1530,6 @@ export class HostedConversationNodeService {
|
|
|
1496
1530
|
}
|
|
1497
1531
|
processExecutor(paths, version, temporaryDirectory = paths.temporaryRootDirectory, generatedImagesDirectory, generatedImagesLogicalPath) {
|
|
1498
1532
|
return this.options.processExecutorFactory({
|
|
1499
|
-
policyVersion: HOST_POLICY_VERSION,
|
|
1500
1533
|
workspaceDirectory: version.templateDirectory,
|
|
1501
1534
|
...(version.sourceWorkspacePath === undefined
|
|
1502
1535
|
? {}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION } from '@myagentroam/agent/sandbox/linux';
|
|
2
|
+
import { MarAgentError } from '@myagentroam/agent';
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
/** Compiles Node-owned hosted-conversation directories into the generic Agent sandbox profile. */
|
|
5
|
+
export function hostedConversationSandboxProfile(input) {
|
|
6
|
+
if ((input.generatedImagesDirectory === undefined) !==
|
|
7
|
+
(input.generatedImagesLogicalPath === undefined))
|
|
8
|
+
throw new MarAgentError('SANDBOX_POLICY_INVALID', 'Sandbox policy is invalid.');
|
|
9
|
+
const mounts = [
|
|
10
|
+
{
|
|
11
|
+
source: input.workspaceDirectory,
|
|
12
|
+
destination: '/workspace',
|
|
13
|
+
access: 'READ_ONLY'
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
source: input.inboxDirectory,
|
|
17
|
+
destination: '/workspace/inbox',
|
|
18
|
+
access: 'READ_WRITE'
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
source: input.outputDirectory,
|
|
22
|
+
destination: '/workspace/output',
|
|
23
|
+
access: 'READ_WRITE'
|
|
24
|
+
}
|
|
25
|
+
];
|
|
26
|
+
if (input.generatedImagesDirectory !== undefined &&
|
|
27
|
+
input.generatedImagesLogicalPath !== undefined)
|
|
28
|
+
mounts.splice(1, 0, {
|
|
29
|
+
source: input.generatedImagesDirectory,
|
|
30
|
+
destination: input.generatedImagesLogicalPath,
|
|
31
|
+
access: 'READ_ONLY'
|
|
32
|
+
});
|
|
33
|
+
if (input.sourceWorkspacePath !== undefined)
|
|
34
|
+
mounts.splice(1, 0, {
|
|
35
|
+
source: input.workspaceDirectory,
|
|
36
|
+
destination: input.sourceWorkspacePath,
|
|
37
|
+
access: 'READ_ONLY'
|
|
38
|
+
}, {
|
|
39
|
+
source: input.inboxDirectory,
|
|
40
|
+
destination: posix.join(input.sourceWorkspacePath, 'inbox'),
|
|
41
|
+
access: 'READ_WRITE'
|
|
42
|
+
}, {
|
|
43
|
+
source: input.outputDirectory,
|
|
44
|
+
destination: posix.join(input.sourceWorkspacePath, 'output'),
|
|
45
|
+
access: 'READ_WRITE'
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
policyVersion: LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION,
|
|
49
|
+
networkMode: input.networkMode,
|
|
50
|
+
mounts,
|
|
51
|
+
temporaryDirectory: {
|
|
52
|
+
kind: 'MOUNT',
|
|
53
|
+
source: input.temporaryDirectory
|
|
54
|
+
},
|
|
55
|
+
workingDirectory: { kind: 'REQUEST' }
|
|
56
|
+
};
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.78",
|
|
4
4
|
"description": "MyAgentRoam Node runtime CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"node-pty": "1.1.0",
|
|
29
29
|
"ws": "^8.21.3",
|
|
30
30
|
"zod": "4.4.3",
|
|
31
|
-
"@myagentroam/agent": "0.9.
|
|
32
|
-
"@myagentroam/protocol": "0.9.
|
|
31
|
+
"@myagentroam/agent": "0.9.78",
|
|
32
|
+
"@myagentroam/protocol": "0.9.78"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/ws": "^8.18.1"
|
|
@@ -1,543 +0,0 @@
|
|
|
1
|
-
import { execFile, spawn } from 'node:child_process';
|
|
2
|
-
import { closeSync, existsSync, lstatSync, openSync, readlinkSync, rmSync } from 'node:fs';
|
|
3
|
-
import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
6
|
-
import { promisify } from 'node:util';
|
|
7
|
-
import { MarAgentError } from '@myagentroam/agent';
|
|
8
|
-
const execFileAsync = promisify(execFile);
|
|
9
|
-
const DEFAULT_BUBBLEWRAP_PATH = '/usr/bin/bwrap';
|
|
10
|
-
const SANDBOX_POLICY_VERSION = 1;
|
|
11
|
-
const SANDBOX_PATH = '/runtime/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
12
|
-
const CURSOR_SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
|
|
13
|
-
const SANDBOX_CPU_SECONDS = 300;
|
|
14
|
-
const SANDBOX_PROCESS_LIMIT = 64;
|
|
15
|
-
const SANDBOX_OPEN_FILE_LIMIT = 256;
|
|
16
|
-
const SANDBOX_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
|
|
17
|
-
const SAFE_ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u;
|
|
18
|
-
const GENERATED_IMAGES_SANDBOX_ROOT = '/home/mar/.mar/agent/sessions';
|
|
19
|
-
// Fontconfig and LibreOffice resolve required host configuration through absolute /etc paths
|
|
20
|
-
// and /usr symlinks. Keep this allowlist read-only and never broaden it to the full /etc tree.
|
|
21
|
-
const READ_ONLY_SYSTEM_CONFIGURATION_DIRECTORIES = ['/etc/fonts', '/etc/libreoffice'];
|
|
22
|
-
const FORBIDDEN_ENVIRONMENT_NAMES = new Set([
|
|
23
|
-
'PATH',
|
|
24
|
-
'HOME',
|
|
25
|
-
'TMPDIR',
|
|
26
|
-
'LD_PRELOAD',
|
|
27
|
-
'LD_LIBRARY_PATH',
|
|
28
|
-
'NODE_OPTIONS',
|
|
29
|
-
'PYTHONPATH',
|
|
30
|
-
'PYTHONHOME',
|
|
31
|
-
'BASH_ENV',
|
|
32
|
-
'ENV',
|
|
33
|
-
'SSH_AUTH_SOCK'
|
|
34
|
-
]);
|
|
35
|
-
const RESERVED_SANDBOX_ROOTS = [
|
|
36
|
-
'/workspace',
|
|
37
|
-
'/home/mar',
|
|
38
|
-
'/runtime',
|
|
39
|
-
'/usr',
|
|
40
|
-
'/bin',
|
|
41
|
-
'/sbin',
|
|
42
|
-
'/lib',
|
|
43
|
-
'/lib64',
|
|
44
|
-
...READ_ONLY_SYSTEM_CONFIGURATION_DIRECTORIES,
|
|
45
|
-
'/proc',
|
|
46
|
-
'/dev',
|
|
47
|
-
'/run'
|
|
48
|
-
];
|
|
49
|
-
/** Compiles one hosted-conversation process request into the fixed Linux Bubblewrap policy. */
|
|
50
|
-
export class BubblewrapSandboxExecutor {
|
|
51
|
-
options;
|
|
52
|
-
#bubblewrapPath;
|
|
53
|
-
constructor(options) {
|
|
54
|
-
this.options = options;
|
|
55
|
-
this.#bubblewrapPath = options.bubblewrapPath ?? DEFAULT_BUBBLEWRAP_PATH;
|
|
56
|
-
}
|
|
57
|
-
async spawn(request) {
|
|
58
|
-
request.signal?.throwIfAborted();
|
|
59
|
-
if (process.platform !== 'linux')
|
|
60
|
-
throw unavailable();
|
|
61
|
-
if (this.options.sourceWorkspacePath !== undefined)
|
|
62
|
-
sandboxCompatibilityPath(this.options.sourceWorkspacePath);
|
|
63
|
-
if (this.options.policyVersion !== SANDBOX_POLICY_VERSION ||
|
|
64
|
-
!['NONE', 'UNRESTRICTED'].includes(this.options.networkMode))
|
|
65
|
-
throw policyInvalid();
|
|
66
|
-
if ((this.options.generatedImagesDirectory === undefined) !==
|
|
67
|
-
(this.options.generatedImagesLogicalPath === undefined))
|
|
68
|
-
throw policyInvalid();
|
|
69
|
-
if (this.options.generatedImagesLogicalPath !== undefined)
|
|
70
|
-
generatedImagesSandboxPath(this.options.generatedImagesLogicalPath);
|
|
71
|
-
await validateBackend(this.#bubblewrapPath);
|
|
72
|
-
const mounts = await this.#mounts();
|
|
73
|
-
const cwd = await sandboxPath(request.cwd, mounts);
|
|
74
|
-
const command = sandboxCommand(request, mounts);
|
|
75
|
-
const launchDirectory = await mkdtemp(join(tmpdir(), 'mar-node-bwrap-launch-'));
|
|
76
|
-
const argumentPath = join(launchDirectory, 'arguments');
|
|
77
|
-
const seccompPath = join(launchDirectory, 'seccomp.bpf');
|
|
78
|
-
try {
|
|
79
|
-
const arguments_ = this.#arguments(mounts, cwd, request);
|
|
80
|
-
await Promise.all([
|
|
81
|
-
writeFile(argumentPath, `${arguments_.join('\0')}\0`, { mode: 0o600, flag: 'wx' }),
|
|
82
|
-
writeFile(seccompPath, seccompFilter(process.arch), { mode: 0o600, flag: 'wx' })
|
|
83
|
-
]);
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
await rm(launchDirectory, { recursive: true, force: true }).catch(() => undefined);
|
|
87
|
-
throw error instanceof MarAgentError ? error : policyInvalid(error);
|
|
88
|
-
}
|
|
89
|
-
let argumentFd;
|
|
90
|
-
let seccompFd;
|
|
91
|
-
try {
|
|
92
|
-
argumentFd = openSync(argumentPath, 'r');
|
|
93
|
-
seccompFd = openSync(seccompPath, 'r');
|
|
94
|
-
rmSync(launchDirectory, { recursive: true, force: true });
|
|
95
|
-
request.signal?.throwIfAborted();
|
|
96
|
-
try {
|
|
97
|
-
const child = spawn(this.#bubblewrapPath, ['--args', '3', '--', ...command], {
|
|
98
|
-
cwd: '/',
|
|
99
|
-
shell: false,
|
|
100
|
-
stdio: ['pipe', 'pipe', 'pipe', argumentFd, seccompFd],
|
|
101
|
-
windowsHide: true,
|
|
102
|
-
detached: request.detached,
|
|
103
|
-
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' }
|
|
104
|
-
});
|
|
105
|
-
return child;
|
|
106
|
-
}
|
|
107
|
-
catch (error) {
|
|
108
|
-
throw unavailable(error);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
finally {
|
|
112
|
-
if (argumentFd !== undefined)
|
|
113
|
-
closeSync(argumentFd);
|
|
114
|
-
if (seccompFd !== undefined)
|
|
115
|
-
closeSync(seccompFd);
|
|
116
|
-
rmSync(launchDirectory, { recursive: true, force: true });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
async #mounts() {
|
|
120
|
-
const definitions = [
|
|
121
|
-
[this.options.workspaceDirectory, '/workspace', false],
|
|
122
|
-
[this.options.inboxDirectory, '/workspace/inbox', true],
|
|
123
|
-
[this.options.outputDirectory, '/workspace/output', true],
|
|
124
|
-
[this.options.temporaryDirectory, '/tmp', true]
|
|
125
|
-
];
|
|
126
|
-
if (this.options.generatedImagesDirectory !== undefined &&
|
|
127
|
-
this.options.generatedImagesLogicalPath !== undefined)
|
|
128
|
-
definitions.splice(1, 0, [
|
|
129
|
-
this.options.generatedImagesDirectory,
|
|
130
|
-
this.options.generatedImagesLogicalPath,
|
|
131
|
-
false
|
|
132
|
-
]);
|
|
133
|
-
if (this.options.sourceWorkspacePath !== undefined)
|
|
134
|
-
definitions.splice(1, 0, [this.options.workspaceDirectory, this.options.sourceWorkspacePath, false], [this.options.inboxDirectory, posix.join(this.options.sourceWorkspacePath, 'inbox'), true], [this.options.outputDirectory, posix.join(this.options.sourceWorkspacePath, 'output'), true]);
|
|
135
|
-
const mounts = [];
|
|
136
|
-
for (const [source, destination, writable] of definitions) {
|
|
137
|
-
mounts.push({ source: await secureDirectory(source), destination, writable });
|
|
138
|
-
}
|
|
139
|
-
for (const [index, mount] of mounts.entries()) {
|
|
140
|
-
for (const other of mounts.slice(index + 1)) {
|
|
141
|
-
if (mount.source === other.source) {
|
|
142
|
-
if (mount.writable !== other.writable)
|
|
143
|
-
throw policyInvalid();
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (within(mount.source, other.source) || within(other.source, mount.source))
|
|
147
|
-
throw policyInvalid();
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const ordered = [...mounts].sort(compareMountDestinations);
|
|
151
|
-
await prepareNestedMountDestinations(ordered);
|
|
152
|
-
return ordered;
|
|
153
|
-
}
|
|
154
|
-
#arguments(mounts, cwd, request) {
|
|
155
|
-
const args = [
|
|
156
|
-
'--unshare-user',
|
|
157
|
-
'--unshare-pid',
|
|
158
|
-
...(this.options.networkMode === 'UNRESTRICTED' ? [] : ['--unshare-net']),
|
|
159
|
-
'--unshare-uts',
|
|
160
|
-
'--unshare-ipc',
|
|
161
|
-
'--unshare-cgroup-try',
|
|
162
|
-
'--disable-userns',
|
|
163
|
-
'--die-with-parent',
|
|
164
|
-
'--new-session',
|
|
165
|
-
'--cap-drop',
|
|
166
|
-
'ALL',
|
|
167
|
-
'--hostname',
|
|
168
|
-
'mar-hosted',
|
|
169
|
-
'--clearenv',
|
|
170
|
-
'--setenv',
|
|
171
|
-
'PATH',
|
|
172
|
-
SANDBOX_PATH,
|
|
173
|
-
'--setenv',
|
|
174
|
-
'HOME',
|
|
175
|
-
'/home/mar',
|
|
176
|
-
'--setenv',
|
|
177
|
-
'LANG',
|
|
178
|
-
'C.UTF-8',
|
|
179
|
-
'--setenv',
|
|
180
|
-
'LC_ALL',
|
|
181
|
-
'C.UTF-8',
|
|
182
|
-
'--setenv',
|
|
183
|
-
'TMPDIR',
|
|
184
|
-
'/tmp',
|
|
185
|
-
'--tmpfs',
|
|
186
|
-
'/',
|
|
187
|
-
...runtimeMountArguments(),
|
|
188
|
-
...sandboxDirectoryArguments(mounts),
|
|
189
|
-
'--proc',
|
|
190
|
-
'/proc',
|
|
191
|
-
'--dev',
|
|
192
|
-
'/dev',
|
|
193
|
-
'--tmpfs',
|
|
194
|
-
'/run'
|
|
195
|
-
];
|
|
196
|
-
for (const mount of mounts)
|
|
197
|
-
args.push(mount.writable ? '--bind' : '--ro-bind', mount.source, mount.destination);
|
|
198
|
-
if (request.owner !== undefined) {
|
|
199
|
-
if (!CURSOR_SAFE_ID.test(request.owner.sessionId) ||
|
|
200
|
-
!CURSOR_SAFE_ID.test(request.owner.executionId))
|
|
201
|
-
throw policyInvalid();
|
|
202
|
-
args.push('--setenv', 'MAR_AGENT_SESSION_ID', request.owner.sessionId, '--setenv', 'MAR_AGENT_EXECUTION_ID', request.owner.executionId);
|
|
203
|
-
}
|
|
204
|
-
for (const [name, value] of explicitEnvironment(request))
|
|
205
|
-
args.push('--setenv', name, value);
|
|
206
|
-
args.push('--seccomp', '4', '--chdir', cwd);
|
|
207
|
-
return args;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
export async function probeBubblewrapSandbox(bubblewrapPath = DEFAULT_BUBBLEWRAP_PATH) {
|
|
211
|
-
if (process.platform !== 'linux')
|
|
212
|
-
throw unavailable();
|
|
213
|
-
const versionOutput = await execFileAsync(bubblewrapPath, ['--version'], {
|
|
214
|
-
timeout: 5_000,
|
|
215
|
-
windowsHide: true
|
|
216
|
-
}).catch((error) => {
|
|
217
|
-
throw unavailable(error);
|
|
218
|
-
});
|
|
219
|
-
const version = /^bubblewrap\s+([0-9]+(?:\.[0-9]+){1,3})\s*$/u.exec(versionOutput.stdout)?.[1];
|
|
220
|
-
if (version === undefined)
|
|
221
|
-
throw unavailable();
|
|
222
|
-
const root = await mkdtemp(join(tmpdir(), 'mar-node-bwrap-probe-'));
|
|
223
|
-
const directories = Object.fromEntries(['workspace', 'inbox', 'output', 'tmp'].map((name) => [name, join(root, name)]));
|
|
224
|
-
try {
|
|
225
|
-
await Promise.all(Object.values(directories).map((path) => mkdir(path, { mode: 0o700 })));
|
|
226
|
-
await Promise.all(['inbox', 'output'].map((name) => mkdir(join(directories.workspace, name), { recursive: true, mode: 0o700 })));
|
|
227
|
-
const executor = new BubblewrapSandboxExecutor({
|
|
228
|
-
bubblewrapPath,
|
|
229
|
-
policyVersion: SANDBOX_POLICY_VERSION,
|
|
230
|
-
workspaceDirectory: directories.workspace,
|
|
231
|
-
sourceWorkspacePath: '/home/mar-sandbox-probe/workspace',
|
|
232
|
-
inboxDirectory: directories.inbox,
|
|
233
|
-
outputDirectory: directories.output,
|
|
234
|
-
temporaryDirectory: directories.tmp,
|
|
235
|
-
networkMode: 'NONE'
|
|
236
|
-
});
|
|
237
|
-
const child = await executor.spawn({
|
|
238
|
-
command: '/bin/true',
|
|
239
|
-
cwd: directories.workspace,
|
|
240
|
-
shell: false,
|
|
241
|
-
environment: {},
|
|
242
|
-
detached: true,
|
|
243
|
-
owner: { sessionId: 'sandbox-probe', executionId: 'sandbox-probe' }
|
|
244
|
-
});
|
|
245
|
-
const result = await waitForProbe(child);
|
|
246
|
-
if (result.code !== 0)
|
|
247
|
-
throw unavailable(new Error(result.stderr));
|
|
248
|
-
return { version };
|
|
249
|
-
}
|
|
250
|
-
finally {
|
|
251
|
-
await rm(root, { recursive: true, force: true }).catch(() => undefined);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
async function waitForProbe(child) {
|
|
255
|
-
const chunks = [];
|
|
256
|
-
let bytes = 0;
|
|
257
|
-
child.stderr.on('data', (chunk) => {
|
|
258
|
-
if (bytes >= 8_192)
|
|
259
|
-
return;
|
|
260
|
-
const remaining = 8_192 - bytes;
|
|
261
|
-
chunks.push(chunk.subarray(0, remaining));
|
|
262
|
-
bytes += Math.min(chunk.length, remaining);
|
|
263
|
-
});
|
|
264
|
-
child.stdin.end();
|
|
265
|
-
return await new Promise((resolve, reject) => {
|
|
266
|
-
const timer = setTimeout(() => {
|
|
267
|
-
child.kill('SIGKILL');
|
|
268
|
-
reject(unavailable(new Error('Sandbox probe timed out.')));
|
|
269
|
-
}, 5_000);
|
|
270
|
-
timer.unref();
|
|
271
|
-
child.once('error', (error) => {
|
|
272
|
-
clearTimeout(timer);
|
|
273
|
-
reject(unavailable(error));
|
|
274
|
-
});
|
|
275
|
-
child.once('close', (code) => {
|
|
276
|
-
clearTimeout(timer);
|
|
277
|
-
resolve({ code, stderr: Buffer.concat(chunks).toString('utf8') });
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
async function validateBackend(path) {
|
|
282
|
-
try {
|
|
283
|
-
const metadata = await lstat(path);
|
|
284
|
-
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o111) === 0)
|
|
285
|
-
throw unavailable();
|
|
286
|
-
}
|
|
287
|
-
catch (error) {
|
|
288
|
-
if (error instanceof MarAgentError)
|
|
289
|
-
throw error;
|
|
290
|
-
throw unavailable(error);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
async function secureDirectory(path) {
|
|
294
|
-
if (!isAbsolute(path))
|
|
295
|
-
throw policyInvalid();
|
|
296
|
-
try {
|
|
297
|
-
const metadata = await lstat(path);
|
|
298
|
-
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
299
|
-
throw policyInvalid();
|
|
300
|
-
const canonical = await realpath(path);
|
|
301
|
-
if (canonical !== resolve(path))
|
|
302
|
-
throw policyInvalid();
|
|
303
|
-
return canonical;
|
|
304
|
-
}
|
|
305
|
-
catch (error) {
|
|
306
|
-
if (error instanceof MarAgentError)
|
|
307
|
-
throw error;
|
|
308
|
-
throw policyInvalid(error);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
async function sandboxPath(path, mounts) {
|
|
312
|
-
const canonical = await secureDirectory(path);
|
|
313
|
-
const mount = [...mounts]
|
|
314
|
-
.sort((left, right) => right.source.length - left.source.length)
|
|
315
|
-
.find((candidate) => canonical === candidate.source || within(candidate.source, canonical));
|
|
316
|
-
if (mount === undefined)
|
|
317
|
-
throw policyInvalid();
|
|
318
|
-
const suffix = relative(mount.source, canonical).split(sep).filter(Boolean);
|
|
319
|
-
return suffix.length === 0 ? mount.destination : posix.join(mount.destination, ...suffix);
|
|
320
|
-
}
|
|
321
|
-
function sandboxCommand(request, mounts) {
|
|
322
|
-
const command = request.shell === undefined || request.shell === false
|
|
323
|
-
? [sandboxExecutable(request.command, mounts), ...(request.args ?? [])]
|
|
324
|
-
: [
|
|
325
|
-
sandboxExecutable(typeof request.shell === 'string' ? request.shell : '/bin/sh', mounts),
|
|
326
|
-
'-c',
|
|
327
|
-
request.command,
|
|
328
|
-
...(request.args ?? [])
|
|
329
|
-
];
|
|
330
|
-
return [
|
|
331
|
-
'/usr/bin/prlimit',
|
|
332
|
-
`--cpu=${SANDBOX_CPU_SECONDS}:${SANDBOX_CPU_SECONDS}`,
|
|
333
|
-
`--nproc=${SANDBOX_PROCESS_LIMIT}:${SANDBOX_PROCESS_LIMIT}`,
|
|
334
|
-
`--nofile=${SANDBOX_OPEN_FILE_LIMIT}:${SANDBOX_OPEN_FILE_LIMIT}`,
|
|
335
|
-
`--fsize=${SANDBOX_FILE_SIZE_BYTES}:${SANDBOX_FILE_SIZE_BYTES}`,
|
|
336
|
-
'--',
|
|
337
|
-
...command
|
|
338
|
-
];
|
|
339
|
-
}
|
|
340
|
-
function sandboxExecutable(command, mounts) {
|
|
341
|
-
if (!isAbsolute(command))
|
|
342
|
-
return command;
|
|
343
|
-
const canonical = resolve(command);
|
|
344
|
-
const nodeRuntimeDirectory = resolve(process.execPath, '..', '..');
|
|
345
|
-
if (nodeRuntimeDirectory !== '/usr' &&
|
|
346
|
-
(canonical === nodeRuntimeDirectory || within(nodeRuntimeDirectory, canonical))) {
|
|
347
|
-
const suffix = relative(nodeRuntimeDirectory, canonical).split(sep).filter(Boolean);
|
|
348
|
-
return suffix.length === 0 ? '/runtime/node' : posix.join('/runtime/node', ...suffix);
|
|
349
|
-
}
|
|
350
|
-
const mount = [...mounts]
|
|
351
|
-
.sort((left, right) => right.source.length - left.source.length)
|
|
352
|
-
.find((candidate) => canonical === candidate.source || within(candidate.source, canonical));
|
|
353
|
-
if (mount !== undefined) {
|
|
354
|
-
const suffix = relative(mount.source, canonical).split(sep).filter(Boolean);
|
|
355
|
-
return suffix.length === 0 ? mount.destination : posix.join(mount.destination, ...suffix);
|
|
356
|
-
}
|
|
357
|
-
if (['/usr', '/bin', '/sbin', '/lib', '/lib64'].some((root) => canonical === root || within(root, canonical)))
|
|
358
|
-
return canonical;
|
|
359
|
-
throw policyInvalid();
|
|
360
|
-
}
|
|
361
|
-
function explicitEnvironment(request) {
|
|
362
|
-
const entries = [];
|
|
363
|
-
const seen = new Set();
|
|
364
|
-
for (const name of request.environmentKeys ?? []) {
|
|
365
|
-
if (seen.has(name))
|
|
366
|
-
continue;
|
|
367
|
-
seen.add(name);
|
|
368
|
-
if (!SAFE_ENVIRONMENT_NAME.test(name) || FORBIDDEN_ENVIRONMENT_NAMES.has(name))
|
|
369
|
-
throw policyInvalid();
|
|
370
|
-
const value = request.environment[name];
|
|
371
|
-
if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 32_768)
|
|
372
|
-
throw policyInvalid();
|
|
373
|
-
entries.push([name, value]);
|
|
374
|
-
}
|
|
375
|
-
return entries;
|
|
376
|
-
}
|
|
377
|
-
function sandboxCompatibilityPath(path) {
|
|
378
|
-
if (!isAbsolute(path) ||
|
|
379
|
-
path.includes('\0') ||
|
|
380
|
-
path === '/' ||
|
|
381
|
-
path === '/tmp' ||
|
|
382
|
-
resolve(path) !== path ||
|
|
383
|
-
RESERVED_SANDBOX_ROOTS.some((root) => path === root || within(root, path) || within(path, root)))
|
|
384
|
-
throw policyInvalid();
|
|
385
|
-
return path;
|
|
386
|
-
}
|
|
387
|
-
function generatedImagesSandboxPath(path) {
|
|
388
|
-
const segments = relative(GENERATED_IMAGES_SANDBOX_ROOT, path).split(sep).filter(Boolean);
|
|
389
|
-
if (!isAbsolute(path) ||
|
|
390
|
-
path.includes('\0') ||
|
|
391
|
-
resolve(path) !== path ||
|
|
392
|
-
!within(GENERATED_IMAGES_SANDBOX_ROOT, path) ||
|
|
393
|
-
segments.length !== 2 ||
|
|
394
|
-
!safePathSegment(segments[0]) ||
|
|
395
|
-
segments[1] !== 'generated_images')
|
|
396
|
-
throw policyInvalid();
|
|
397
|
-
return path;
|
|
398
|
-
}
|
|
399
|
-
function safePathSegment(value) {
|
|
400
|
-
return (value.length > 0 &&
|
|
401
|
-
value.length <= 255 &&
|
|
402
|
-
value !== '.' &&
|
|
403
|
-
value !== '..' &&
|
|
404
|
-
!value.includes('/') &&
|
|
405
|
-
!value.includes('\\'));
|
|
406
|
-
}
|
|
407
|
-
function compareMountDestinations(left, right) {
|
|
408
|
-
const depth = left.destination.split('/').filter(Boolean).length -
|
|
409
|
-
right.destination.split('/').filter(Boolean).length;
|
|
410
|
-
return depth === 0 ? left.destination.localeCompare(right.destination, 'en-US') : depth;
|
|
411
|
-
}
|
|
412
|
-
async function prepareNestedMountDestinations(mounts) {
|
|
413
|
-
for (const [index, mount] of mounts.entries()) {
|
|
414
|
-
const parent = mounts
|
|
415
|
-
.slice(0, index)
|
|
416
|
-
.filter((candidate) => within(candidate.destination, mount.destination))
|
|
417
|
-
.sort((left, right) => right.destination.length - left.destination.length)[0];
|
|
418
|
-
if (parent === undefined)
|
|
419
|
-
continue;
|
|
420
|
-
const suffix = relative(parent.destination, mount.destination).split(sep).filter(Boolean);
|
|
421
|
-
const target = join(parent.source, ...suffix);
|
|
422
|
-
if (parent.writable) {
|
|
423
|
-
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
try {
|
|
427
|
-
const metadata = await lstat(target);
|
|
428
|
-
if (!metadata.isDirectory() ||
|
|
429
|
-
metadata.isSymbolicLink() ||
|
|
430
|
-
(await realpath(target)) !== resolve(target))
|
|
431
|
-
throw policyInvalid();
|
|
432
|
-
}
|
|
433
|
-
catch (error) {
|
|
434
|
-
if (error instanceof MarAgentError)
|
|
435
|
-
throw error;
|
|
436
|
-
throw policyInvalid(error);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
function sandboxDirectoryArguments(mounts) {
|
|
441
|
-
const directories = new Set(['/home', '/home/mar']);
|
|
442
|
-
for (const mount of mounts) {
|
|
443
|
-
const segments = mount.destination.split('/').filter(Boolean);
|
|
444
|
-
let current = '';
|
|
445
|
-
for (const segment of segments) {
|
|
446
|
-
current = `${current}/${segment}`;
|
|
447
|
-
directories.add(current);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
return [...directories]
|
|
451
|
-
.sort((left, right) => {
|
|
452
|
-
const depth = left.split('/').filter(Boolean).length - right.split('/').filter(Boolean).length;
|
|
453
|
-
return depth === 0 ? left.localeCompare(right, 'en-US') : depth;
|
|
454
|
-
})
|
|
455
|
-
.flatMap((directory) => ['--dir', directory]);
|
|
456
|
-
}
|
|
457
|
-
function runtimeMountArguments() {
|
|
458
|
-
const args = [];
|
|
459
|
-
const nodeRuntimeDirectory = resolve(process.execPath, '..', '..');
|
|
460
|
-
if (nodeRuntimeDirectory !== '/usr')
|
|
461
|
-
args.push('--dir', '/runtime', '--dir', '/runtime/node', '--ro-bind', nodeRuntimeDirectory, '/runtime/node');
|
|
462
|
-
for (const path of ['/usr', '/bin', '/sbin', '/lib', '/lib64']) {
|
|
463
|
-
if (!existsSync(path))
|
|
464
|
-
continue;
|
|
465
|
-
const metadata = lstatSync(path);
|
|
466
|
-
if (metadata.isSymbolicLink())
|
|
467
|
-
args.push('--symlink', readlinkSync(path), path);
|
|
468
|
-
else if (metadata.isDirectory())
|
|
469
|
-
args.push('--ro-bind', path, path);
|
|
470
|
-
}
|
|
471
|
-
for (const path of READ_ONLY_SYSTEM_CONFIGURATION_DIRECTORIES) {
|
|
472
|
-
if (!existsSync(path) || !lstatSync(path).isDirectory())
|
|
473
|
-
continue;
|
|
474
|
-
args.push('--ro-bind', path, path);
|
|
475
|
-
}
|
|
476
|
-
for (const path of [
|
|
477
|
-
'/etc/host.conf',
|
|
478
|
-
'/etc/hosts',
|
|
479
|
-
'/etc/ld.so.cache',
|
|
480
|
-
'/etc/resolv.conf',
|
|
481
|
-
'/etc/passwd',
|
|
482
|
-
'/etc/group',
|
|
483
|
-
'/etc/nsswitch.conf',
|
|
484
|
-
'/etc/localtime',
|
|
485
|
-
'/etc/ssl/certs'
|
|
486
|
-
]) {
|
|
487
|
-
if (existsSync(path))
|
|
488
|
-
args.push('--ro-bind', path, path);
|
|
489
|
-
}
|
|
490
|
-
return args;
|
|
491
|
-
}
|
|
492
|
-
function seccompFilter(architecture) {
|
|
493
|
-
const policy = syscallPolicy(architecture);
|
|
494
|
-
const filters = [
|
|
495
|
-
[0x20, 0, 0, 4],
|
|
496
|
-
[0x15, 1, 0, policy.auditArchitecture],
|
|
497
|
-
[0x06, 0, 0, 0x80000000],
|
|
498
|
-
[0x20, 0, 0, 0]
|
|
499
|
-
];
|
|
500
|
-
if (architecture === 'x64')
|
|
501
|
-
filters.push([0x54, 0, 0, 0x40000000], [0x15, 1, 0, 0], [0x06, 0, 0, 0x80000000], [0x20, 0, 0, 0]);
|
|
502
|
-
for (const syscall of policy.blockedSyscalls)
|
|
503
|
-
filters.push([0x15, 0, 1, syscall], [0x06, 0, 0, 0x00050001]);
|
|
504
|
-
filters.push([0x06, 0, 0, 0x7fff0000]);
|
|
505
|
-
const buffer = Buffer.alloc(filters.length * 8);
|
|
506
|
-
for (const [index, [code, jumpTrue, jumpFalse, value]] of filters.entries()) {
|
|
507
|
-
const offset = index * 8;
|
|
508
|
-
buffer.writeUInt16LE(code, offset);
|
|
509
|
-
buffer.writeUInt8(jumpTrue, offset + 2);
|
|
510
|
-
buffer.writeUInt8(jumpFalse, offset + 3);
|
|
511
|
-
buffer.writeUInt32LE(value >>> 0, offset + 4);
|
|
512
|
-
}
|
|
513
|
-
return buffer;
|
|
514
|
-
}
|
|
515
|
-
function syscallPolicy(architecture) {
|
|
516
|
-
if (architecture === 'x64')
|
|
517
|
-
return {
|
|
518
|
-
auditArchitecture: 0xc000003e,
|
|
519
|
-
blockedSyscalls: [
|
|
520
|
-
101, 155, 165, 166, 175, 176, 246, 248, 249, 250, 272, 298, 304, 308, 313, 321, 323, 428,
|
|
521
|
-
429, 430, 431, 432, 442
|
|
522
|
-
]
|
|
523
|
-
};
|
|
524
|
-
if (architecture === 'arm64')
|
|
525
|
-
return {
|
|
526
|
-
auditArchitecture: 0xc00000b7,
|
|
527
|
-
blockedSyscalls: [
|
|
528
|
-
39, 40, 41, 97, 104, 105, 106, 117, 217, 218, 219, 241, 265, 268, 273, 280, 282, 428, 429,
|
|
529
|
-
430, 431, 432, 442
|
|
530
|
-
]
|
|
531
|
-
};
|
|
532
|
-
throw policyInvalid();
|
|
533
|
-
}
|
|
534
|
-
function within(parent, candidate) {
|
|
535
|
-
const relation = relative(parent, candidate);
|
|
536
|
-
return relation.length > 0 && relation !== '..' && !relation.startsWith(`..${sep}`);
|
|
537
|
-
}
|
|
538
|
-
function unavailable(cause) {
|
|
539
|
-
return new MarAgentError('SANDBOX_UNAVAILABLE', 'Bubblewrap sandbox is unavailable.', { cause });
|
|
540
|
-
}
|
|
541
|
-
function policyInvalid(cause) {
|
|
542
|
-
return new MarAgentError('SANDBOX_POLICY_INVALID', 'Sandbox policy is invalid.', { cause });
|
|
543
|
-
}
|