@factory/droid-sdk 0.9.1 → 0.10.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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Review the complete custom-model edit flow against the isolated daemon
3
+ * started by custom-model-editing-disposable.sh. No provider call is made.
4
+ *
5
+ * From the repository root:
6
+ * bash packages/droid-sdk-ext/examples/node/custom-model-editing-disposable.sh
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import { connectToDaemon } from '@factory/droid-sdk';
14
+
15
+ const home = process.env.BYOK_REPRO_HOME;
16
+ if (
17
+ !home ||
18
+ home !== process.env.HOME ||
19
+ home !== process.env.FACTORY_HOME_OVERRIDE ||
20
+ process.env.FACTORY_AIRGAP_ENABLED !== 'true' ||
21
+ !existsSync(join(home, '.byok-review-scratch'))
22
+ ) {
23
+ throw new Error('Run this example through its disposable daemon script.');
24
+ }
25
+
26
+ const url = process.env.DROID_DAEMON_URL;
27
+ const apiKey = process.env.FACTORY_API_KEY;
28
+ if (!url?.startsWith('ws://127.0.0.1:') || !apiKey) {
29
+ throw new Error('Expected the isolated loopback daemon and test auth key.');
30
+ }
31
+
32
+ const settingsPath = join(home, '.factory-dev', 'settings.json');
33
+ if (existsSync(settingsPath)) {
34
+ throw new Error('Refusing to edit a pre-existing settings file.');
35
+ }
36
+
37
+ const model = 'review-synthetic-model';
38
+ const byokKey = `review-${randomBytes(16).toString('hex')}`;
39
+
40
+ function assertStored(displayName: string): void {
41
+ const raw: unknown = JSON.parse(readFileSync(settingsPath, 'utf8'));
42
+ assert.ok(raw && typeof raw === 'object' && 'customModels' in raw);
43
+ const models = raw.customModels;
44
+ assert.ok(Array.isArray(models) && models.length === 1);
45
+ const stored: unknown = models[0];
46
+ assert.ok(stored && typeof stored === 'object');
47
+ assert.ok(
48
+ 'apiKey' in stored && stored.apiKey === byokKey,
49
+ 'The synthetic BYOK key was not retained on disk.'
50
+ );
51
+ assert.ok(
52
+ 'displayName' in stored && stored.displayName === displayName,
53
+ 'The model name did not persist.'
54
+ );
55
+ assert.ok(
56
+ 'useInRouter' in stored && stored.useInRouter === true,
57
+ 'The JSON-authored router opt-in did not persist.'
58
+ );
59
+ }
60
+
61
+ function assertRpcError(error: unknown, code: number, reason?: string): void {
62
+ if (!(error instanceof Error && 'error' in error)) {
63
+ throw new Error('Expected a structured daemon RPC error.');
64
+ }
65
+ const rpcError = error.error;
66
+ if (!(rpcError && typeof rpcError === 'object' && 'code' in rpcError)) {
67
+ throw new Error('Expected a structured daemon RPC error.');
68
+ }
69
+ assert.ok(rpcError.code === code, 'Unexpected daemon RPC error code.');
70
+ if (reason !== undefined) {
71
+ assert.ok('data' in rpcError, 'Expected a structured conflict reason.');
72
+ const data = rpcError.data;
73
+ assert.ok(
74
+ data &&
75
+ typeof data === 'object' &&
76
+ 'reason' in data &&
77
+ data.reason === reason,
78
+ 'Unexpected daemon conflict reason.'
79
+ );
80
+ }
81
+ }
82
+
83
+ const droid = await connectToDaemon({ url, auth: { apiKey } });
84
+ try {
85
+ const empty = await droid.customModels.listWithRevision();
86
+ assert.ok(empty.revision && empty.models.length === 0);
87
+
88
+ await droid.customModels.upsert({
89
+ model,
90
+ provider: 'anthropic',
91
+ expectedRevision: empty.revision,
92
+ configuration: {
93
+ model,
94
+ provider: 'anthropic',
95
+ baseUrl: 'https://example.invalid/anthropic',
96
+ displayName: 'Created',
97
+ apiKey: byokKey,
98
+ useInRouter: true,
99
+ },
100
+ });
101
+ assertStored('Created');
102
+ console.log('PASS: complete create persisted in the isolated settings file');
103
+
104
+ const opening = await droid.customModels.listWithRevision();
105
+ const row = opening.models[0];
106
+ assert.ok(opening.revision && row?.editableConfig);
107
+ assert.ok(row.isValid && row.hasApiKey);
108
+ assert.ok(!Object.hasOwn(row.editableConfig, 'apiKey'));
109
+ assert.ok(!JSON.stringify(opening).includes(byokKey));
110
+ assert.deepEqual(row.redactedSecrets, [['apiKey']]);
111
+ assert.ok(row.editableConfig.useInRouter === true);
112
+
113
+ await droid.customModels.upsert({
114
+ rawIndex: row.rawIndex,
115
+ expectedModel: row.model,
116
+ model: row.model,
117
+ provider: row.provider,
118
+ expectedRevision: opening.revision,
119
+ configuration: { ...row.editableConfig, displayName: 'Edited' },
120
+ preservedSecrets: row.redactedSecrets,
121
+ });
122
+ assertStored('Edited');
123
+ console.log('PASS: redacted edit retained the synthetic credential');
124
+
125
+ const current = await droid.customModels.listWithRevision();
126
+ assert.ok(current.revision && current.revision !== opening.revision);
127
+ assert.ok(current.models[0]?.editableConfig);
128
+
129
+ let staleRejected = false;
130
+ try {
131
+ await droid.customModels.upsert({
132
+ rawIndex: row.rawIndex,
133
+ expectedModel: row.model,
134
+ model: row.model,
135
+ provider: row.provider,
136
+ expectedRevision: opening.revision,
137
+ configuration: { ...row.editableConfig, displayName: 'Stale overwrite' },
138
+ preservedSecrets: row.redactedSecrets,
139
+ });
140
+ } catch (error) {
141
+ assertRpcError(error, -32006, 'stale_revision');
142
+ staleRejected = true;
143
+ }
144
+ assert.ok(staleRejected, 'The stale write unexpectedly succeeded.');
145
+ assertStored('Edited');
146
+ console.log('PASS: stale edit returned CONFLICT and changed nothing');
147
+
148
+ let invalidRejected = false;
149
+ try {
150
+ await droid.customModels.upsert({
151
+ rawIndex: row.rawIndex,
152
+ expectedModel: row.model,
153
+ model: row.model,
154
+ provider: row.provider,
155
+ expectedRevision: current.revision,
156
+ configuration: {
157
+ ...current.models[0].editableConfig,
158
+ unexpectedField: 'not supported',
159
+ },
160
+ preservedSecrets: current.models[0].redactedSecrets,
161
+ });
162
+ } catch (error) {
163
+ assertRpcError(error, -32602);
164
+ invalidRejected = true;
165
+ }
166
+ assert.ok(invalidRejected, 'The invalid edit unexpectedly succeeded.');
167
+ assertStored('Edited');
168
+ console.log('PASS: invalid complete edit returned INVALID_PARAMS');
169
+ } finally {
170
+ droid.disconnect();
171
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Read a custom-model edit snapshot, or explicitly rename one model.
3
+ *
4
+ * Set DROID_DAEMON_URL and FACTORY_API_KEY for an existing daemon.
5
+ * Read-only: npx tsx examples/node/custom-model-editing.ts
6
+ * Rename: npx tsx examples/node/custom-model-editing.ts <raw-index> <display-name>
7
+ */
8
+
9
+ import { connectToDaemon } from '@factory/droid-sdk';
10
+
11
+ const url = process.env.DROID_DAEMON_URL;
12
+ const apiKey = process.env.FACTORY_API_KEY;
13
+ if (!url || !apiKey) {
14
+ throw new Error('Set DROID_DAEMON_URL and FACTORY_API_KEY.');
15
+ }
16
+
17
+ const [rawIndexText, displayName] = process.argv.slice(2);
18
+ const rawIndex = rawIndexText === undefined ? undefined : Number(rawIndexText);
19
+ if (
20
+ rawIndex !== undefined &&
21
+ (!Number.isInteger(rawIndex) || rawIndex < 0 || displayName === undefined)
22
+ ) {
23
+ throw new Error(
24
+ 'Pass a non-negative raw index and a display name to rename.'
25
+ );
26
+ }
27
+
28
+ const droid = await connectToDaemon({ url, auth: { apiKey } });
29
+ try {
30
+ // list() remains the array-only display API. Editors need this snapshot.
31
+ const snapshot = await droid.customModels.listWithRevision();
32
+ console.log(`Found ${snapshot.models.length} personal custom models.`);
33
+ if (rawIndex !== undefined) {
34
+ const model = snapshot.models.find((entry) => entry.rawIndex === rawIndex);
35
+ if (!snapshot.revision || !model?.editableConfig) {
36
+ throw new Error(
37
+ 'Model is missing or the daemon does not support full editing.'
38
+ );
39
+ }
40
+ await droid.customModels.upsert({
41
+ rawIndex: model.rawIndex,
42
+ expectedModel: model.model,
43
+ expectedRevision: snapshot.revision,
44
+ model: model.model,
45
+ provider: model.provider,
46
+ configuration: { ...model.editableConfig, displayName },
47
+ preservedSecrets: model.redactedSecrets,
48
+ });
49
+ console.log(
50
+ 'Model renamed. Reload the snapshot before the next edit or delete.'
51
+ );
52
+ }
53
+ } finally {
54
+ droid.disconnect();
55
+ }
@@ -45,10 +45,11 @@ try {
45
45
  const session = await createSession({
46
46
  cwd: projectDir,
47
47
  permissionHandler({ toolUses, options }) {
48
+ const [toolUse] = toolUses;
48
49
  const approvesExpectedCommand =
49
50
  toolUses.length === 1 &&
50
- toolUses[0].details.type === ToolConfirmationType.Execute &&
51
- toolUses[0].details.fullCommand === expectedCommand;
51
+ toolUse?.details.type === ToolConfirmationType.Execute &&
52
+ toolUse.details.fullCommand === expectedCommand;
52
53
  const desired = approvesExpectedCommand
53
54
  ? ToolConfirmationOutcome.ProceedOnce
54
55
  : ToolConfirmationOutcome.Cancel;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * SDK MCP tool example.
3
3
  *
4
- * Defines an in-process tool with `createSdkMcpServer` + `tool` and exposes it
5
- * to Droid, then asks Droid to call it.
4
+ * Defines an in-process read-only tool with `createSdkMcpServer` + `tool` and
5
+ * exposes it to Droid, then asks Droid to call it.
6
6
  *
7
7
  * Usage:
8
8
  * npx tsx examples/node/sdk-mcp-tool.ts
@@ -25,7 +25,9 @@ const sdkTools = createSdkMcpServer({
25
25
  'favorite_number',
26
26
  'Returns a favorite number for a person',
27
27
  { name: z.string() },
28
- ({ name }) => `${name}'s favorite number is 42.`
28
+ ({ name }) => `${name}'s favorite number is 42.`,
29
+ // Read-only tools run without a permission prompt.
30
+ { annotations: { readOnlyHint: true } }
29
31
  ),
30
32
  ],
31
33
  });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tool controls example.
3
3
  *
4
- * Disables tools at session creation, then inspects availability.
4
+ * Disables tools at session creation, then inspects availability and schemas.
5
5
  *
6
6
  * Usage:
7
7
  * npx tsx examples/node/tool-controls.ts
@@ -14,12 +14,14 @@ const session = await createSession({
14
14
  });
15
15
 
16
16
  try {
17
- const tools = await session.listTools();
17
+ const tools = await session.listTools({
18
+ includeSchemas: true,
19
+ toolIds: ['Read', 'Grep', 'Execute'],
20
+ });
18
21
 
19
22
  for (const tool of tools) {
20
- if (['Read', 'Grep', 'Execute'].includes(tool.id)) {
21
- console.log(`${tool.id}: ${String(tool.allowed)}`);
22
- }
23
+ console.log(`${tool.id}: ${String(tool.allowed)}`);
24
+ console.log(JSON.stringify(tool.schemas, null, 2));
23
25
  }
24
26
  } finally {
25
27
  await session.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factory/droid-sdk",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "TypeScript SDK for the Factory Droid CLI",
6
6
  "keywords": [
@@ -59,6 +59,7 @@
59
59
  "eventemitter3": "^5.0.1",
60
60
  "fast-deep-equal": "^3.1.3",
61
61
  "isows": "^1.0.7",
62
+ "libsodium-wrappers-sumo": "^0.8.4",
62
63
  "uuid": "^9.0.0",
63
64
  "ws": "^8.21.0",
64
65
  "zod": "^3.24.0"