@inneranimalmedia/agentsam-sdk 2.3.0 → 2.4.1
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/examples/cad/blender-box-with-hole.recipe.json +71 -0
- package/package.json +3 -2
- package/packages/identity/package.json +1 -1
- package/protocol/cad/blender-recipe.schema.json +92 -0
- package/protocol/capabilities/manifest.json +76 -0
- package/services/cad/blender/adapter.py +603 -0
- package/src/cli.js +9 -0
- package/src/commands/cad.js +188 -0
- package/src/lib/auth.js +10 -19
- package/src/lib/cad/blender.js +340 -0
- package/src/lib/cad/index.js +15 -0
- package/src/lib/open-url.js +66 -0
- package/test/blender-cad.test.mjs +146 -0
- package/test/open-url.test.mjs +64 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
BLENDER_ADAPTER_PATH,
|
|
8
|
+
blenderBuild,
|
|
9
|
+
blenderInspect,
|
|
10
|
+
blenderStatus,
|
|
11
|
+
createBlenderInvocation,
|
|
12
|
+
discoverBlender,
|
|
13
|
+
parseBlenderResult,
|
|
14
|
+
validateBlenderRecipe,
|
|
15
|
+
} from '../src/lib/cad/index.js';
|
|
16
|
+
|
|
17
|
+
function fixture(t) {
|
|
18
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-blender-test-'));
|
|
19
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
20
|
+
const binary = path.join(root, process.platform === 'win32' ? 'blender.exe' : 'blender');
|
|
21
|
+
fs.writeFileSync(binary, 'fake blender\n');
|
|
22
|
+
const blend = path.join(root, 'source.blend');
|
|
23
|
+
fs.writeFileSync(blend, 'blend fixture\n');
|
|
24
|
+
return { root, binary, blend };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resultEnvelope(value) {
|
|
28
|
+
return { code: 0, stdout: `Blender startup noise\nAGENTSAM_RESULT=${JSON.stringify(value)}\n`, stderr: '' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('Blender adapter is packaged at the SDK-owned fixed path', () => {
|
|
32
|
+
assert.equal(path.basename(BLENDER_ADAPTER_PATH), 'adapter.py');
|
|
33
|
+
assert.ok(fs.existsSync(BLENDER_ADAPTER_PATH));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('discoverBlender honors explicit binary before environment discovery', t => {
|
|
37
|
+
const { binary } = fixture(t);
|
|
38
|
+
assert.equal(discoverBlender({ blenderBin: binary, env: { AGENTSAM_BLENDER_BIN: '/wrong' } }), fs.realpathSync(binary));
|
|
39
|
+
assert.throws(() => discoverBlender({ blenderBin: path.join(path.dirname(binary), 'missing') }), /not found/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('Blender status uses argv execution and returns version without shell parsing', async t => {
|
|
43
|
+
const { binary } = fixture(t);
|
|
44
|
+
const calls = [];
|
|
45
|
+
const result = await blenderStatus({
|
|
46
|
+
blenderBin: binary,
|
|
47
|
+
runProcessImpl: async (command, args) => {
|
|
48
|
+
calls.push({ command, args });
|
|
49
|
+
return { code: 0, stdout: 'Blender 4.5.3 LTS\n', stderr: '' };
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
assert.equal(result.available, true);
|
|
53
|
+
assert.equal(result.version, 'Blender 4.5.3 LTS');
|
|
54
|
+
assert.deepEqual(calls, [{ command: fs.realpathSync(binary), args: ['--version'] }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('typed Blender recipe rejects arbitrary or unknown operations', () => {
|
|
58
|
+
const valid = validateBlenderRecipe({
|
|
59
|
+
schema_version: 1,
|
|
60
|
+
operations: [
|
|
61
|
+
{ op: 'add', primitive: 'cube', name: 'Body', size: 20 },
|
|
62
|
+
{ op: 'bevel', object: 'Body', width: 1, segments: 3 },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
assert.equal(valid.operations.length, 2);
|
|
66
|
+
assert.throws(() => validateBlenderRecipe({ schema_version: 1, operations: [{ op: 'python', code: 'import bpy' }] }), /unsupported Blender recipe operation/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('Blender invocation is background + fixed adapter + typed request, never shell code', t => {
|
|
70
|
+
const { binary, blend, root } = fixture(t);
|
|
71
|
+
const requestPath = path.join(root, 'request.json');
|
|
72
|
+
const value = createBlenderInvocation({ operation: 'inspect', input: blend, requestPath, blenderBin: binary });
|
|
73
|
+
assert.equal(value.command, binary);
|
|
74
|
+
assert.deepEqual(value.args.slice(0, 2), ['--background', blend]);
|
|
75
|
+
assert.ok(value.args.includes('--python'));
|
|
76
|
+
assert.ok(value.args.includes(BLENDER_ADAPTER_PATH));
|
|
77
|
+
assert.deepEqual(value.args.slice(-4), ['--operation', 'inspect', '--request', requestPath]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('parseBlenderResult ignores Blender logs and reads the machine envelope', () => {
|
|
81
|
+
assert.deepEqual(parseBlenderResult('noise\nAGENTSAM_RESULT={"ok":true,"value":7}\n'), { ok: true, value: 7 });
|
|
82
|
+
assert.throws(() => parseBlenderResult('noise only'), /did not return/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('blenderInspect returns source hash and scene evidence without writing source', async t => {
|
|
86
|
+
const { binary, blend } = fixture(t);
|
|
87
|
+
const before = fs.readFileSync(blend, 'utf8');
|
|
88
|
+
const result = await blenderInspect({
|
|
89
|
+
input: blend,
|
|
90
|
+
blenderBin: binary,
|
|
91
|
+
runProcessImpl: async () => resultEnvelope({
|
|
92
|
+
ok: true,
|
|
93
|
+
blender_version: '4.5.3',
|
|
94
|
+
scene: { active: 'Scene', objects: [{ name: 'Body', type: 'MESH' }] },
|
|
95
|
+
warnings: [],
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
assert.equal(result.capability, 'blender.inspect');
|
|
99
|
+
assert.equal(result.scene.objects[0].name, 'Body');
|
|
100
|
+
assert.match(result.input.sha256, /^[a-f0-9]{64}$/);
|
|
101
|
+
assert.equal(fs.readFileSync(blend, 'utf8'), before);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('blenderBuild executes a bounded recipe and produces a hashed .blend artifact', async t => {
|
|
105
|
+
const { binary, root } = fixture(t);
|
|
106
|
+
const output = path.join(root, 'built.blend');
|
|
107
|
+
const recipe = {
|
|
108
|
+
schema_version: 1,
|
|
109
|
+
units: { system: 'METRIC', scale_length: 0.001, length_unit: 'MILLIMETERS' },
|
|
110
|
+
operations: [
|
|
111
|
+
{ op: 'clear_scene' },
|
|
112
|
+
{ op: 'add', primitive: 'cube', name: 'Body', size: 20 },
|
|
113
|
+
{ op: 'add', primitive: 'cylinder', name: 'Hole', radius: 3, depth: 30 },
|
|
114
|
+
{ op: 'boolean', object: 'Body', with: 'Hole', operation: 'DIFFERENCE', delete_operand: true },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
let seenRequest;
|
|
118
|
+
const result = await blenderBuild({
|
|
119
|
+
output,
|
|
120
|
+
recipe,
|
|
121
|
+
blenderBin: binary,
|
|
122
|
+
runProcessImpl: async (_command, args) => {
|
|
123
|
+
const requestPath = args[args.indexOf('--request') + 1];
|
|
124
|
+
seenRequest = JSON.parse(fs.readFileSync(requestPath, 'utf8'));
|
|
125
|
+
fs.writeFileSync(seenRequest.output, 'generated blend artifact\n');
|
|
126
|
+
return resultEnvelope({ ok: true, blender_version: '4.5.3', operations_applied: recipe.operations.length, objects: ['Body'], warnings: [] });
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
assert.equal(seenRequest.recipe.operations[3].operation, 'DIFFERENCE');
|
|
130
|
+
assert.equal(result.capability, 'blender.build');
|
|
131
|
+
assert.equal(result.artifact.path, output);
|
|
132
|
+
assert.equal(result.artifact.format, 'blend');
|
|
133
|
+
assert.match(result.artifact.sha256, /^[a-f0-9]{64}$/);
|
|
134
|
+
assert.deepEqual(result.objects, ['Body']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('Blender build refuses to overwrite an input source .blend', async t => {
|
|
138
|
+
const { binary, blend } = fixture(t);
|
|
139
|
+
await assert.rejects(() => blenderBuild({
|
|
140
|
+
input: blend,
|
|
141
|
+
output: blend,
|
|
142
|
+
blenderBin: binary,
|
|
143
|
+
recipe: { schema_version: 1, operations: [{ op: 'clear_scene' }] },
|
|
144
|
+
runProcessImpl: async () => { throw new Error('runner should not execute'); },
|
|
145
|
+
}), /never overwrite/);
|
|
146
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { browserCommand, promptToOpenUrl } from '../src/lib/open-url.js';
|
|
5
|
+
|
|
6
|
+
test('browserCommand uses argv-safe platform launchers', () => {
|
|
7
|
+
assert.deepEqual(browserCommand('https://inneranimalmedia.com/auth', 'darwin'), {
|
|
8
|
+
command: 'open',
|
|
9
|
+
args: ['https://inneranimalmedia.com/auth'],
|
|
10
|
+
});
|
|
11
|
+
assert.deepEqual(browserCommand('https://inneranimalmedia.com/auth', 'linux'), {
|
|
12
|
+
command: 'xdg-open',
|
|
13
|
+
args: ['https://inneranimalmedia.com/auth'],
|
|
14
|
+
});
|
|
15
|
+
assert.deepEqual(browserCommand('https://inneranimalmedia.com/auth', 'win32'), {
|
|
16
|
+
command: 'cmd',
|
|
17
|
+
args: ['/c', 'start', '', 'https://inneranimalmedia.com/auth'],
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('browserCommand rejects non-http protocols', () => {
|
|
22
|
+
assert.throws(() => browserCommand('file:///tmp/example', 'darwin'), /Unsupported URL protocol/);
|
|
23
|
+
assert.throws(() => browserCommand('javascript:alert(1)', 'darwin'), /Unsupported URL protocol/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('promptToOpenUrl stays non-interactive when stdin is not a TTY', async () => {
|
|
27
|
+
const input = new PassThrough();
|
|
28
|
+
const output = new PassThrough();
|
|
29
|
+
let text = '';
|
|
30
|
+
output.on('data', chunk => { text += chunk.toString(); });
|
|
31
|
+
let opened = false;
|
|
32
|
+
const result = await promptToOpenUrl('https://www.blender.org/download/', {
|
|
33
|
+
input,
|
|
34
|
+
output,
|
|
35
|
+
openImpl: () => { opened = true; },
|
|
36
|
+
});
|
|
37
|
+
assert.equal(result.interactive, false);
|
|
38
|
+
assert.equal(result.opened, false);
|
|
39
|
+
assert.equal(opened, false);
|
|
40
|
+
assert.match(text, /https:\/\/www\.blender\.org\/download\//);
|
|
41
|
+
assert.match(text, /Open the URL above in a browser/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('promptToOpenUrl waits for Enter before opening in a TTY', async () => {
|
|
45
|
+
const input = new PassThrough();
|
|
46
|
+
const output = new PassThrough();
|
|
47
|
+
input.isTTY = true;
|
|
48
|
+
output.isTTY = true;
|
|
49
|
+
let text = '';
|
|
50
|
+
output.on('data', chunk => { text += chunk.toString(); });
|
|
51
|
+
let openedUrl = null;
|
|
52
|
+
const pending = promptToOpenUrl('https://inneranimalmedia.com/dashboard', {
|
|
53
|
+
input,
|
|
54
|
+
output,
|
|
55
|
+
openImpl: url => { openedUrl = url; },
|
|
56
|
+
});
|
|
57
|
+
input.write('\n');
|
|
58
|
+
const result = await pending;
|
|
59
|
+
assert.equal(result.interactive, true);
|
|
60
|
+
assert.equal(result.opened, true);
|
|
61
|
+
assert.equal(openedUrl, 'https://inneranimalmedia.com/dashboard');
|
|
62
|
+
assert.match(text, /Press ENTER to open/);
|
|
63
|
+
assert.match(text, /Browser opened/);
|
|
64
|
+
});
|