@inneranimalmedia/agentsam-sdk 2.4.1 → 2.6.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.
Files changed (233) hide show
  1. package/AGENTSAM.md +55 -0
  2. package/README.md +17 -12
  3. package/bin/agentsam +2 -0
  4. package/docs/AGENTSAM_ASTRA_OPENAI_INTEGRATION.md +1363 -0
  5. package/docs/AGENTSAM_WORKBENCH.md +30 -0
  6. package/docs/AUTH_IDENTITY_CONTRACT.md +52 -0
  7. package/docs/CAPABILITIES.md +5 -3
  8. package/docs/CLI_SHELL.md +167 -80
  9. package/docs/CMS_STUDIO.md +101 -0
  10. package/docs/CONTEXT.md +170 -0
  11. package/docs/LOCAL_OLLAMA.md +58 -0
  12. package/docs/MERKLE.md +19 -19
  13. package/docs/PORTABLE_CONTEXT.md +4 -3
  14. package/docs/PROJECT_CONFIG.md +72 -0
  15. package/docs/RELEASES.md +35 -2
  16. package/docs/REPOSITORY_INTELLIGENCE.md +1 -1
  17. package/docs/REPOSITORY_KNOWLEDGE.md +114 -0
  18. package/docs/SDK_WORKER.md +86 -0
  19. package/docs/SECURITY.md +59 -22
  20. package/docs/client-cms-editor.md +15 -0
  21. package/docs/local-studio/WORKMODE_DONOR_NOTES.md +485 -0
  22. package/package.json +35 -10
  23. package/packages/connectors/cloudflare/package.json +10 -0
  24. package/packages/connectors/cloudflare/src/index.js +127 -0
  25. package/packages/connectors/cloudflare/src/owner.js +76 -0
  26. package/packages/connectors/cloudflare/src/routes.js +223 -0
  27. package/packages/connectors/cloudflare/src/vault.js +80 -0
  28. package/packages/connectors/cloudflare/tests/connector.test.mjs +44 -0
  29. package/packages/identity/package.json +10 -1
  30. package/packages/identity/src/contracts/auth-config.js +109 -0
  31. package/packages/identity/src/index.js +1 -0
  32. package/packages/identity/src/oauth/README.md +2 -2
  33. package/packages/identity/src/oauth/credentials.js +11 -4
  34. package/packages/identity/src/oauth/iam-platform.js +3 -3
  35. package/packages/identity/src/providers/iam/index.js +7 -7
  36. package/packages/identity/src/providers/iam/oauth.js +6 -4
  37. package/packages/identity/src/providers/iam/profile.js +5 -5
  38. package/packages/identity/tests/auth-config.test.mjs +61 -0
  39. package/packages/identity/tests/oauth-credentials.test.mjs +13 -2
  40. package/protocol/FILEMETA_V1.md +95 -0
  41. package/protocol/INSPECT_VIEWS_V1.md +40 -0
  42. package/protocol/MERKLE_PERSISTENCE_V1.md +50 -0
  43. package/protocol/MERKLE_V1.md +3 -1
  44. package/protocol/README.md +1 -0
  45. package/protocol/capabilities/cloudflare-cpu-audit-input.schema.json +19 -0
  46. package/protocol/capabilities/cloudflare-cpu-profile-input.schema.json +13 -0
  47. package/protocol/capabilities/cloudflare-wrangler-native-input.schema.json +19 -0
  48. package/protocol/capabilities/manifest.json +79 -3
  49. package/protocol/capabilities/repository-snapshot.schema.json +1 -0
  50. package/protocol/context/context-budget.schema.json +36 -0
  51. package/protocol/context/context-item.schema.json +19 -0
  52. package/protocol/context/resolved-context-pack.schema.json +47 -0
  53. package/protocol/context/result-policy.schema.json +17 -0
  54. package/protocol/knowledge/context-pack.schema.json +33 -0
  55. package/protocol/knowledge/retrieval-query.schema.json +68 -13
  56. package/protocol/models/README.md +373 -0
  57. package/protocol/models/model-inventory-v2.schema.json +212 -0
  58. package/python/README.md +15 -7
  59. package/python/agentsam_sdk/cli.py +0 -21
  60. package/python/agentsam_sdk/tui/README.md +17 -12
  61. package/python/agentsam_sdk/tui/bootstrap.py +2 -2
  62. package/python/agentsam_sdk/tui/demo.py +25 -10
  63. package/python/agentsam_sdk/tui/onboarding.py +208 -0
  64. package/python/tests/test_tui_cli.py +7 -6
  65. package/skills/README.md +21 -0
  66. package/skills/agentsam-app-fundamentals/SKILL.md +165 -0
  67. package/skills/agentsam-app-fundamentals/references/graphs-contracts-ast-merkle.md +89 -0
  68. package/skills/agentsam-app-fundamentals/references/trust-credentials-and-destinations.md +99 -0
  69. package/skills/agentsam-cloudflare-workers/SKILL.md +53 -0
  70. package/skills/agentsam-cloudflare-workers/references/cpu-profiling.md +16 -0
  71. package/skills/agentsam-cloudflare-workers/references/errors-and-observability.md +29 -0
  72. package/skills/agentsam-cloudflare-workers/references/wrangler-native-map.md +28 -0
  73. package/skills/agentsam-jr-dev/SKILL.md +232 -0
  74. package/skills/agentsam-jr-dev/references/real-application-logic.md +156 -0
  75. package/skills/agentsam-jr-dev/references/web-application-fundamentals.md +240 -0
  76. package/skills/agentsam-progression-guard/SKILL.md +197 -0
  77. package/skills/agentsam-progression-guard/references/checkpoint-chain.md +111 -0
  78. package/skills/agentsam-progression-guard/references/hooks-operational-io.md +96 -0
  79. package/skills/catalog.json +71 -0
  80. package/src/agent/capability-adapter.js +25 -13
  81. package/src/agent/index.js +1 -0
  82. package/src/agent/responses-runner.js +325 -0
  83. package/src/capabilities/index.js +7 -0
  84. package/src/capabilities/repository-snapshot-view.js +238 -0
  85. package/src/capabilities/repository-snapshot.js +28 -14
  86. package/src/cli.js +198 -76
  87. package/src/cloudflare/cpu-profile.js +115 -0
  88. package/src/cloudflare/index.js +14 -0
  89. package/src/cloudflare/wrangler.js +132 -0
  90. package/src/commands/account-auth.js +47 -0
  91. package/src/commands/cloudflare.js +58 -0
  92. package/src/commands/connections.js +93 -0
  93. package/src/commands/context-economics.js +114 -0
  94. package/src/commands/context.js +14 -2
  95. package/src/commands/db.js +4 -7
  96. package/src/commands/deploy.js +54 -32
  97. package/src/commands/eval.js +63 -0
  98. package/src/commands/interactive.js +18 -0
  99. package/src/commands/knowledge.js +27 -4
  100. package/src/commands/merkle-persist.js +118 -0
  101. package/src/commands/merkle.js +32 -17
  102. package/src/commands/models.js +152 -0
  103. package/src/commands/ollama.js +259 -0
  104. package/src/commands/preferences.js +144 -0
  105. package/src/commands/product.js +86 -16
  106. package/src/commands/resume.js +67 -0
  107. package/src/commands/security.js +8 -6
  108. package/src/commands/shell.js +405 -100
  109. package/src/commands/skills.js +66 -0
  110. package/src/commands/start-local.js +1 -1
  111. package/src/commands/tunnel.js +5 -4
  112. package/src/commands/whoami.js +86 -0
  113. package/src/context/budget.js +120 -0
  114. package/src/context/compact.js +28 -0
  115. package/src/context/index.js +7 -0
  116. package/src/context/rehydrate.js +35 -0
  117. package/src/context/resolve.js +116 -0
  118. package/src/context/result-policy.js +66 -0
  119. package/src/errors/diagnostic.js +160 -0
  120. package/src/errors/index.js +9 -0
  121. package/src/eval/context.js +191 -0
  122. package/src/eval/index.js +1 -0
  123. package/src/index.js +70 -0
  124. package/src/indexing/execution-boundary.js +144 -0
  125. package/src/indexing/index.js +8 -0
  126. package/src/indexing/provider.js +41 -0
  127. package/src/knowledge/config.js +2 -2
  128. package/src/knowledge/context-pack.js +12 -1
  129. package/src/knowledge/contracts.js +9 -4
  130. package/src/knowledge/engine.js +7 -3
  131. package/src/knowledge/service/server.js +1 -1
  132. package/src/lib/account-session.js +98 -0
  133. package/src/lib/agent-instructions.js +73 -0
  134. package/src/lib/auth.js +4 -0
  135. package/src/lib/bridge-client.js +7 -5
  136. package/src/lib/cli-preferences.js +78 -0
  137. package/src/lib/core-client.js +8 -8
  138. package/src/lib/deploy/git-guard.js +69 -0
  139. package/src/lib/deploy/health.js +57 -0
  140. package/src/lib/deploy/local-studio.js +283 -0
  141. package/src/lib/deploy/secret-scan.js +65 -0
  142. package/src/lib/deploy-receipt/index.js +5 -2
  143. package/src/lib/detect-context.js +6 -5
  144. package/src/lib/execution-approvals.js +59 -0
  145. package/src/lib/identity-scaffold.js +1 -1
  146. package/src/lib/local-scaffold.js +35 -33
  147. package/src/lib/local-sessions.js +127 -0
  148. package/src/lib/local-status.js +9 -17
  149. package/src/lib/merkle/cloudflare-persistence.js +321 -0
  150. package/src/lib/merkle/filemeta.js +43 -0
  151. package/src/lib/merkle/git-ignore.js +24 -0
  152. package/src/lib/merkle/hash.js +1 -0
  153. package/src/lib/merkle/index.js +22 -0
  154. package/src/lib/merkle/persistence.js +72 -0
  155. package/src/lib/merkle/semantic.js +359 -0
  156. package/src/lib/merkle/snapshot.js +9 -3
  157. package/src/lib/merkle/tree.js +11 -6
  158. package/src/lib/project-config.js +227 -0
  159. package/src/lib/project-rules.js +68 -0
  160. package/src/lib/provider-credentials.js +83 -0
  161. package/src/lib/save-sdk-token.js +1 -1
  162. package/src/lib/scaffold/templates/worker-api/index.js +101 -20
  163. package/src/lib/scaffold/wizards/worker-api.js +27 -11
  164. package/src/lib/slash-commands.js +22 -15
  165. package/src/lib/tools.js +11 -5
  166. package/src/models/catalog.js +135 -0
  167. package/src/models/index.js +7 -0
  168. package/src/providers/index.js +5 -0
  169. package/src/providers/openai-responses.js +275 -0
  170. package/src/security/index.js +1 -0
  171. package/src/security/inventory.js +4 -1
  172. package/src/security/process.js +35 -9
  173. package/src/security/render.js +27 -5
  174. package/src/security/scan.js +24 -9
  175. package/src/security/trust-boundary.js +24 -0
  176. package/src/skills/index.js +64 -0
  177. package/src/telemetry/contracts.js +203 -0
  178. package/src/telemetry/events.js +48 -0
  179. package/src/telemetry/index.js +8 -0
  180. package/src/tools/hydrate.js +35 -0
  181. package/src/tools/index.js +2 -0
  182. package/src/tools/search.js +70 -0
  183. package/src/ui/ansi.js +1 -1
  184. package/src/ui/boot.js +54 -0
  185. package/src/ui/merkle/render.js +1 -0
  186. package/src/ui/runtime-activity.js +192 -0
  187. package/src/ui/theme.js +19 -18
  188. package/test/account-session.test.mjs +36 -0
  189. package/test/app-building-skills.test.mjs +61 -0
  190. package/test/apps-scaffold-contract.test.mjs +56 -0
  191. package/test/capabilities.test.mjs +53 -4
  192. package/test/cli-preferences.test.mjs +46 -0
  193. package/test/cloudflare-connector.test.mjs +96 -0
  194. package/test/cloudflare-runtime.test.mjs +75 -0
  195. package/test/context.test.mjs +144 -0
  196. package/test/deploy-health-scan.test.mjs +67 -0
  197. package/test/error-diagnostics.test.mjs +59 -0
  198. package/test/eval-context.test.mjs +37 -0
  199. package/test/execution-approvals.test.mjs +27 -0
  200. package/test/indexing-provider.test.mjs +29 -0
  201. package/test/jr-dev-skill.test.mjs +26 -0
  202. package/test/knowledge-context-pack.test.mjs +19 -0
  203. package/test/knowledge.test.mjs +1 -1
  204. package/test/local-sessions.test.mjs +42 -0
  205. package/test/local-studio-deploy.test.mjs +83 -0
  206. package/test/merkle-persistence.test.mjs +91 -0
  207. package/test/merkle.test.mjs +77 -3
  208. package/test/model-catalog.test.mjs +43 -0
  209. package/test/models.test.mjs +51 -0
  210. package/test/npm10-lock.test.mjs +29 -0
  211. package/test/ollama.test.mjs +94 -0
  212. package/test/openai-responses.test.mjs +95 -0
  213. package/test/project-config.test.mjs +81 -0
  214. package/test/project-rules.test.mjs +44 -0
  215. package/test/provider-credentials.test.mjs +52 -0
  216. package/test/rehydrate.test.mjs +25 -0
  217. package/test/release-hygiene.test.mjs +34 -0
  218. package/test/repository-snapshot-view.test.mjs +112 -0
  219. package/test/responses-runner.test.mjs +148 -0
  220. package/test/runtime-activity.test.mjs +98 -0
  221. package/test/sdk-worker-contract.test.mjs +68 -0
  222. package/test/security.test.mjs +46 -0
  223. package/test/shell.test.mjs +55 -21
  224. package/test/skills.test.mjs +22 -0
  225. package/test/smoke.mjs +5 -2
  226. package/test/telemetry.test.mjs +79 -0
  227. package/test/theme-portability.test.mjs +14 -0
  228. package/test/tools-search.test.mjs +40 -0
  229. package/test/whoami-resume.test.mjs +56 -0
  230. package/examples/agentsam-tui-ansi.mjs +0 -149
  231. package/src/commands/tui.js +0 -120
  232. package/src/ui/splash-xterm.js +0 -290
  233. package/src/ui/splash.js +0 -426
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { buildMerkleTree, saveSnapshot, readSnapshot, validateSnapshot, diffTrees } from '../src/lib/merkle/index.js';
8
+ import { buildMerkleTree, saveSnapshot, readSnapshot, validateSnapshot, diffTrees, metadataRoot } from '../src/lib/merkle/index.js';
9
9
 
10
10
  const cli = fileURLToPath(new URL('../src/cli.js', import.meta.url));
11
11
  async function fixture(t) {
@@ -60,6 +60,55 @@ test('default ignores, explicit dist inclusion, literal exclusions, and empty-di
60
60
  assert.equal(excluded.stats.files, 0);
61
61
  });
62
62
 
63
+ test('semantic metadata adds package/system/category/mode and AST evidence without changing the content root', async (t) => {
64
+ const root = await fixture(t);
65
+ await write(root, 'package.json', JSON.stringify({ name: 'fixture-root', version: '1.0.0' }));
66
+ await write(root, 'packages/identity/package.json', JSON.stringify({
67
+ name: '@example/identity',
68
+ version: '1.0.0',
69
+ agentsam: { system: 'identity', kind: 'library', tags: ['authentication'] },
70
+ }));
71
+ await write(root, 'packages/identity/src/core/accounts.js', "import { helper } from '../shared/helper.js';\nexport function resolveAccount() { return helper; }\n");
72
+ await write(root, 'packages/identity/src/shared/helper.js', 'export const helper = 1;\n');
73
+ await fs.chmod(path.join(root, 'packages/identity/src/core/accounts.js'), 0o644);
74
+
75
+ const plain = await buildMerkleTree(root);
76
+ const enriched = await buildMerkleTree(root, { semantic: true });
77
+ assert.equal(enriched.rootHash, plain.rootHash);
78
+ assert.match(enriched.policyHash, /^sha256:[a-f0-9]{64}$/);
79
+ assert.equal(enriched.policyHash, plain.policyHash);
80
+ assert.equal(enriched.version, 1);
81
+ assert.match(enriched.semantic.rootHash, /^sha256:[a-f0-9]{64}$/);
82
+ assert.equal(enriched.semantic.classifier.format, 'agentsam-filemeta');
83
+ assert.equal(enriched.semantic.classifier.version, 1);
84
+
85
+ const accounts = enriched.semantic.entries.find((entry) => entry.path === 'packages/identity/src/core/accounts.js');
86
+ assert.equal(accounts.mode, 420);
87
+ assert.equal(accounts.package, '@example/identity');
88
+ assert.equal(accounts.package_root, 'packages/identity');
89
+ assert.equal(accounts.package_kind, 'library');
90
+ assert.equal(accounts.system, 'identity');
91
+ assert.equal(accounts.category, 'accounts');
92
+ assert.equal(accounts.layer, 'core');
93
+ assert.equal(accounts.kind, 'source');
94
+ assert.equal(accounts.language, 'javascript');
95
+ assert.equal(accounts.role, 'business-logic');
96
+ assert.deepEqual(accounts.tags, ['authentication']);
97
+ assert.deepEqual(accounts.symbols, ['resolveAccount']);
98
+ assert.deepEqual(accounts.imports, ['../shared/helper.js']);
99
+ assert.equal(accounts.ast.indexed, true);
100
+
101
+ const changedClassifier = { ...enriched.semantic.classifier, version: 2 };
102
+ assert.notEqual(metadataRoot(enriched.semantic.entries, changedClassifier), enriched.semantic.rootHash);
103
+
104
+ await fs.chmod(path.join(root, 'packages/identity/src/core/accounts.js'), 0o755);
105
+ const permissionOnly = await buildMerkleTree(root, { semantic: true });
106
+ const permissionRecord = permissionOnly.semantic.entries.find((entry) => entry.path === 'packages/identity/src/core/accounts.js');
107
+ assert.equal(permissionOnly.rootHash, plain.rootHash);
108
+ assert.equal(permissionRecord.mode, 493);
109
+ assert.notEqual(permissionOnly.semantic.rootHash, enriched.semantic.rootHash);
110
+ });
111
+
63
112
  test('symlinks hash their literal target, including cycles, without reading the target', { skip: process.platform === 'win32' }, async (t) => {
64
113
  const root = await fixture(t), outside = await fixture(t);
65
114
  await write(outside, 'secret.txt', 'not read');
@@ -78,10 +127,14 @@ test('default and custom snapshots exclude themselves and preserve baseline unti
78
127
  const root = await fixture(t);
79
128
  await write(root, 'file.txt', 'baseline');
80
129
  const initial = await buildMerkleTree(root);
81
- const { snapshot, output } = await saveSnapshot(root);
130
+ const { snapshot, output } = await saveSnapshot(root, { semantic: true });
82
131
  assert.equal(initial.rootHash, snapshot.rootHash);
132
+ assert.match(snapshot.semantic.rootHash, /^sha256:[a-f0-9]{64}$/);
83
133
  assert.ok(diffTrees(snapshot, await buildMerkleTree(root, { policy: snapshot.policy })).equal);
84
- assert.equal((await readSnapshot(output)).rootHash, snapshot.rootHash);
134
+ const reloaded = await readSnapshot(output);
135
+ assert.equal(reloaded.rootHash, snapshot.rootHash);
136
+ assert.equal(reloaded.policyHash, snapshot.policyHash);
137
+ assert.equal(reloaded.semantic.rootHash, snapshot.semantic.rootHash);
85
138
  await assert.rejects(saveSnapshot(root), /exists/);
86
139
  await write(root, 'file.txt', 'edited');
87
140
  assert.equal(diffTrees(snapshot, await buildMerkleTree(root, { policy: snapshot.policy })).stats.modified, 1);
@@ -142,3 +195,24 @@ test('CLI snapshot/verify/diff supports JSON, moved roots, and distinct mismatch
142
195
  assert.equal(piped.status, 0, piped.stderr);
143
196
  assert.ok(!piped.stdout.includes('\x1b'));
144
197
  });
198
+
199
+ test('semantic index records execution domains, environment access, and resolved local imports as Merkle-bound evidence', async (t) => {
200
+ const root = await fixture(t);
201
+ await write(root, 'package.json', JSON.stringify({ name: 'boundary-fixture', version: '1.0.0' }));
202
+ await write(root, 'frontend/client.ts', "import { contract } from '../shared/contract.ts';\nexport const api = import.meta.env.VITE_API_URL + contract;\n");
203
+ await write(root, 'shared/contract.ts', "export const contract = 'v1';\n");
204
+ await write(root, 'backend/private.ts', "export const key = process.env.API_KEY;\n");
205
+ const tree = await buildMerkleTree(root, { semantic: true });
206
+ const client = tree.semantic.entries.find((entry) => entry.path === 'frontend/client.ts');
207
+ const shared = tree.semantic.entries.find((entry) => entry.path === 'shared/contract.ts');
208
+ const backend = tree.semantic.entries.find((entry) => entry.path === 'backend/private.ts');
209
+ assert.equal(client.execution_domain, 'browser');
210
+ assert.equal(shared.execution_domain, 'shared');
211
+ assert.equal(backend.execution_domain, 'server');
212
+ assert.deepEqual(client.env_accesses, [{ source: 'import.meta.env', name: 'VITE_API_URL' }]);
213
+ assert.deepEqual(client.resolved_imports, [{ specifier: '../shared/contract.ts', target: 'shared/contract.ts' }]);
214
+ assert.deepEqual(backend.env_accesses, [{ source: 'process.env', name: 'API_KEY' }]);
215
+ assert.equal(tree.semantic.stats.by_execution_domain.browser, 1);
216
+ assert.equal(tree.semantic.stats.by_execution_domain.server, 1);
217
+ assert.equal(tree.semantic.classifier.trust_boundary, 'execution-domain-v1');
218
+ });
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { calculateModelCost, getModelRecord } from '../src/models/index.js';
4
+
5
+ test('Astra catalog separates technical window, reasoning controls, service tiers, and economics', () => {
6
+ const model = getModelRecord('gpt-6-astra');
7
+ assert.ok(model);
8
+ assert.equal(model.context_window, 1_050_000);
9
+ assert.equal(model.max_output_tokens, 128_000);
10
+ assert.deepEqual(model.reasoning_efforts, ['low', 'medium', 'high', 'xhigh', 'max']);
11
+ assert.deepEqual(model.service_tiers, ['default', 'fast', 'flex']);
12
+ assert.equal(model.context_policy.target_input_tokens, 120_000);
13
+ assert.equal(model.context_policy.compact_at_tokens, 180_000);
14
+ assert.equal(model.context_policy.max_normal_input_tokens, 250_000);
15
+ assert.equal(model.context_policy.pricing_threshold_tokens, 272_000);
16
+ assert.equal(model.batch.interactive, false);
17
+ });
18
+
19
+ test('cost calculation applies the long-context threshold to the full request only after crossing it', () => {
20
+ const atBoundary = calculateModelCost('gpt-6-astra', { input_tokens: 272_000, output_tokens: 10_000 });
21
+ assert.equal(atBoundary.threshold_applied, null);
22
+ assert.equal(atBoundary.rates_per_million.input, 10);
23
+ assert.equal(atBoundary.rates_per_million.output, 50);
24
+
25
+ const crossed = calculateModelCost('gpt-6-astra', { input_tokens: 272_001, output_tokens: 10_000 });
26
+ assert.equal(crossed.threshold_applied.input_tokens_gt, 272_000);
27
+ assert.equal(crossed.rates_per_million.input, 20);
28
+ assert.equal(crossed.rates_per_million.output, 75);
29
+ assert.ok(crossed.total_usd > atBoundary.total_usd);
30
+ });
31
+
32
+ test('service tier and cache pricing stay explicit rather than hidden in prompts', () => {
33
+ const usage = { input_tokens: 100_000, cached_input_tokens: 80_000, cache_write_tokens: 10_000, output_tokens: 20_000 };
34
+ const standard = calculateModelCost('gpt-6-astra', usage, { serviceTier: 'default' });
35
+ const fast = calculateModelCost('gpt-6-astra', usage, { serviceTier: 'fast' });
36
+ const flex = calculateModelCost('gpt-6-astra', usage, { serviceTier: 'flex' });
37
+ const batch = calculateModelCost('gpt-6-astra', usage, { serviceTier: 'batch' });
38
+ assert.equal(fast.total_usd, standard.total_usd * 2);
39
+ assert.equal(flex.total_usd, standard.total_usd * 0.5);
40
+ assert.equal(batch.total_usd, standard.total_usd * 0.5);
41
+ assert.equal(standard.rates_per_million.cached_input, 1);
42
+ assert.equal(standard.rates_per_million.cache_write, 12.5);
43
+ });
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { collectModelsStatus, renderModelsStatus } from '../src/commands/models.js';
7
+
8
+ function response(body, status = 200) {
9
+ return { ok: status >= 200 && status < 300, status, async json() { return body; } };
10
+ }
11
+
12
+ function tempHome(t) {
13
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-model-home-'));
14
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
15
+ return home;
16
+ }
17
+
18
+ test('model inventory reports configured API providers without exposing credentials', async t => {
19
+ const home = tempHome(t);
20
+ const status = await collectModelsStatus({
21
+ home,
22
+ discoverRemote: false,
23
+ env: {
24
+ OPENAI_API_KEY: 'secret-openai', GEMINI_API_KEY: '', XAI_API_KEY: 'secret-xai', ANTHROPIC_API_KEY: 'secret-anthropic',
25
+ OLLAMA_BASE_URL: 'http://127.0.0.1:11434', OLLAMA_MODEL: 'qwen:test', OLLAMA_EMBED_MODEL: 'embed:test',
26
+ },
27
+ fetchImpl: async () => response({ models: [{ name: 'qwen:test' }, { name: 'embed:test' }] }),
28
+ });
29
+ assert.equal(status.providers.find((row) => row.id === 'openai').configured, true);
30
+ assert.equal(status.providers.find((row) => row.id === 'gemini').configured, false);
31
+ assert.equal(status.providers.find((row) => row.id === 'grok').configured, true);
32
+ assert.equal(status.providers.find((row) => row.id === 'anthropic').configured, true);
33
+ assert.equal(status.local.online, true);
34
+ assert.deepEqual(status.local.models.map((row) => row.name), ['qwen:test', 'embed:test']);
35
+ const rendered = renderModelsStatus(status);
36
+ assert.match(rendered, /OpenAI/);
37
+ assert.match(rendered, /qwen:test/);
38
+ assert.doesNotMatch(rendered, /secret-openai|secret-xai|secret-anthropic/);
39
+ });
40
+
41
+ test('exact hosted model availability is verified against the provider inventory', async () => {
42
+ const status = await collectModelsStatus({
43
+ env: { OPENAI_API_KEY: 'secret-openai', OLLAMA_BASE_URL: 'http://127.0.0.1:11434' },
44
+ fetchImpl: async () => response({ models: [] }),
45
+ providerFetchImpl: async () => response({ data: [{ id: 'gpt-6-astra' }, { id: 'some-other-model' }] }),
46
+ });
47
+ assert.equal(status.discovery.openai.ok, true);
48
+ assert.equal(status.discovery.openai.returnedModelCount, 2);
49
+ assert.deepEqual(status.availableModels.map((row) => row.provider_model_id), ['gpt-6-astra']);
50
+ assert.equal(status.catalogModels[0].availability, 'available');
51
+ });
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describe, it } from 'node:test';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const lockPath = path.join(root, 'apps/local-studio/package-lock.json');
10
+
11
+ describe('local-studio npm 10 lockfile', () => {
12
+ it('pins lru-cache@11.5.2 so Cloudflare npm ci can resolve it', () => {
13
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
14
+ assert.equal(lock.lockfileVersion, 3);
15
+ const pkgs = lock.packages || {};
16
+ const hits = Object.entries(pkgs).filter(([key, meta]) => key.includes('lru-cache') && meta.version === '11.5.2');
17
+ assert.ok(hits.length >= 1, 'Missing: lru-cache@11.5.2 from lock file');
18
+ const [, meta] = hits[0];
19
+ assert.match(meta.resolved || '', /lru-cache-11\.5\.2\.tgz/);
20
+ assert.ok(meta.integrity);
21
+ });
22
+
23
+ it('verify-npm10-lock script exits 0 against the current lock', () => {
24
+ const script = path.join(root, 'apps/local-studio/scripts/verify-npm10-lock.mjs');
25
+ const r = spawnSync(process.execPath, [script], { encoding: 'utf8' });
26
+ assert.equal(r.status, 0, r.stderr || r.stdout);
27
+ assert.match(r.stdout, /lru-cache@11\.5\.2/);
28
+ });
29
+ });
@@ -0,0 +1,94 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import {
7
+ OLLAMA_DEFAULTS,
8
+ ollamaInstallPlan,
9
+ parseOllamaArgs,
10
+ probeOllama,
11
+ resolveOllamaConfig,
12
+ updateProjectOllamaConfig,
13
+ upsertOllamaEnvFile,
14
+ } from '../src/commands/ollama.js';
15
+ import { createProjectManifest } from '../src/lib/project-config.js';
16
+
17
+ test('Ollama defaults are local-only and match the SDK local-dev contract', () => {
18
+ assert.deepEqual(OLLAMA_DEFAULTS, {
19
+ baseUrl: 'http://127.0.0.1:11434',
20
+ model: 'qwen2.5-coder',
21
+ embedModel: 'mxbai-embed-large',
22
+ });
23
+ assert.deepEqual(resolveOllamaConfig({}, {}), OLLAMA_DEFAULTS);
24
+ });
25
+
26
+ test('Ollama setup args require explicit install/pull/start opt-ins', () => {
27
+ assert.deepEqual(parseOllamaArgs(['setup']), {
28
+ command: 'setup',
29
+ cwd: process.cwd(),
30
+ envFile: '',
31
+ baseUrl: '',
32
+ model: '',
33
+ embedModel: '',
34
+ install: false,
35
+ start: false,
36
+ pull: false,
37
+ json: false,
38
+ });
39
+ const opted = parseOllamaArgs(['setup', '--install', '--start', '--pull', '--json']);
40
+ assert.equal(opted.install, true);
41
+ assert.equal(opted.start, true);
42
+ assert.equal(opted.pull, true);
43
+ assert.equal(opted.json, true);
44
+ });
45
+
46
+ test('setup env writer updates only canonical OLLAMA keys and preserves other config', () => {
47
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-ollama-'));
48
+ const envFile = path.join(root, '.env');
49
+ fs.writeFileSync(envFile, 'EXISTING=value\nOLLAMA_MODEL=old\n');
50
+ upsertOllamaEnvFile(envFile, OLLAMA_DEFAULTS);
51
+ const text = fs.readFileSync(envFile, 'utf8');
52
+ assert.match(text, /^EXISTING=value$/m);
53
+ assert.match(text, /^OLLAMA_BASE_URL=http:\/\/127\.0\.0\.1:11434$/m);
54
+ assert.match(text, /^OLLAMA_MODEL=qwen2\.5-coder$/m);
55
+ assert.match(text, /^OLLAMA_EMBED_MODEL=mxbai-embed-large$/m);
56
+ assert.equal((text.match(/^OLLAMA_MODEL=/gm) || []).length, 1);
57
+ });
58
+
59
+ test('project config records only portable Ollama capability metadata', () => {
60
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-ollama-config-'));
61
+ fs.mkdirSync(path.join(root, '.agentsam'));
62
+ fs.writeFileSync(path.join(root, '.agentsam', 'config.json'), `${JSON.stringify(createProjectManifest({ projectName: 'demo', repositoryId: 'local:demo' }), null, 2)}\n`);
63
+ const filename = updateProjectOllamaConfig(root, OLLAMA_DEFAULTS);
64
+ const config = JSON.parse(fs.readFileSync(filename, 'utf8'));
65
+ assert.equal(config.models.local.provider, 'ollama');
66
+ assert.equal(config.models.local.base_url_env, 'OLLAMA_BASE_URL');
67
+ assert.equal(config.models.local.model_env, 'OLLAMA_MODEL');
68
+ assert.equal(config.models.local.embed_model_env, 'OLLAMA_EMBED_MODEL');
69
+ assert.equal(JSON.stringify(config).includes('127.0.0.1'), false);
70
+ assert.equal(JSON.stringify(config).includes('qwen2.5-coder'), false);
71
+ assert.equal(JSON.stringify(config).includes('token'), false);
72
+ });
73
+
74
+ test('status probe recognizes configured chat and embedding models', async () => {
75
+ const fakeFetch = async () => new Response(JSON.stringify({ models: [
76
+ { name: 'qwen2.5-coder:latest' },
77
+ { name: 'mxbai-embed-large:latest' },
78
+ ] }), { status: 200, headers: { 'content-type': 'application/json' } });
79
+ const status = await probeOllama(OLLAMA_DEFAULTS, fakeFetch);
80
+ assert.equal(status.online, true);
81
+ assert.equal(status.chat_ready, true);
82
+ assert.equal(status.embed_ready, true);
83
+ });
84
+
85
+ test('automatic install plans use local package managers, never a remote shell script', () => {
86
+ assert.deepEqual(ollamaInstallPlan('darwin', { brew: true }), {
87
+ command: 'brew', args: ['install', 'ollama'], manager: 'homebrew',
88
+ });
89
+ assert.equal(ollamaInstallPlan('linux', { brew: false }), null);
90
+ assert.equal(ollamaInstallPlan('darwin', { brew: false }), null);
91
+ const win = ollamaInstallPlan('win32', { winget: true });
92
+ assert.equal(win.command, 'winget');
93
+ assert.equal(win.args.includes('Ollama.Ollama'), true);
94
+ });
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createOpenAIResponsesAdapter } from '../src/providers/index.js';
4
+
5
+ function jsonResponse(body, status = 200) {
6
+ return { ok: status >= 200 && status < 300, status, async json() { return body; } };
7
+ }
8
+
9
+ test('Responses adapter sends reasoning/service tier without legacy sampling parameters and reconciles provider usage', async () => {
10
+ let request = null;
11
+ const events = [];
12
+ const adapter = createOpenAIResponsesAdapter({
13
+ apiKey: 'test-key',
14
+ emit: event => events.push(event),
15
+ fetchImpl: async (url, options) => {
16
+ request = { url, options, body: JSON.parse(options.body) };
17
+ return jsonResponse({
18
+ id: 'resp_test', status: 'completed', model: 'gpt-6-astra', service_tier: 'fast',
19
+ output: [{ type: 'message', content: [{ type: 'output_text', text: 'done' }] }],
20
+ usage: {
21
+ input_tokens: 100_000,
22
+ input_tokens_details: { cached_tokens: 80_000, cache_write_tokens: 10_000 },
23
+ output_tokens: 5_000,
24
+ output_tokens_details: { reasoning_tokens: 2_000 },
25
+ },
26
+ });
27
+ },
28
+ });
29
+ const result = await adapter.create({ model: 'gpt-6-astra', input: 'work', reasoningEffort: 'high', serviceTier: 'fast' });
30
+ assert.equal(request.url, 'https://api.openai.com/v1/responses');
31
+ assert.equal(request.body.reasoning.effort, 'high');
32
+ assert.equal(request.body.service_tier, 'fast');
33
+ assert.equal(request.body.truncation, 'disabled');
34
+ assert.equal('temperature' in request.body, false);
35
+ assert.equal('top_p' in request.body, false);
36
+ assert.equal(result.output_text, 'done');
37
+ assert.equal(result.usage_snapshot.current_context.input_tokens, 100_000);
38
+ assert.equal(result.usage_snapshot.current_context.window_tokens, 1_050_000);
39
+ assert.equal(result.actual_service_tier, 'fast');
40
+ assert.equal(result.cost.rates_per_million.input, 20);
41
+ assert.deepEqual(events.map(event => event.type), ['model.started', 'usage.snapshot', 'cost.snapshot', 'model.completed']);
42
+ });
43
+
44
+ test('Responses adapter preserves function call_id when returning tool output', async () => {
45
+ const bodies = [];
46
+ const adapter = createOpenAIResponsesAdapter({
47
+ apiKey: 'test-key',
48
+ fetchImpl: async (_url, options) => {
49
+ const body = JSON.parse(options.body);
50
+ bodies.push(body);
51
+ if (bodies.length === 1) {
52
+ return jsonResponse({
53
+ id: 'resp_calls', status: 'completed', service_tier: 'default',
54
+ output: [{ type: 'function_call', id: 'fc_1', call_id: 'call_abc', name: 'repository_snapshot', arguments: '{"depth":"quick"}', status: 'completed' }],
55
+ usage: { input_tokens: 100, output_tokens: 20 },
56
+ });
57
+ }
58
+ return jsonResponse({
59
+ id: 'resp_done', status: 'completed', service_tier: 'default',
60
+ output: [{ type: 'message', content: [{ type: 'output_text', text: 'complete' }] }],
61
+ usage: { input_tokens: 200, output_tokens: 30 },
62
+ });
63
+ },
64
+ });
65
+ const first = await adapter.create({
66
+ model: 'gpt-6-astra', input: 'inspect', reasoningEffort: 'low', serviceTier: 'default',
67
+ tools: [{ type: 'function', name: 'repository_snapshot', description: 'Snapshot repo', parameters: { type: 'object', properties: {} } }],
68
+ });
69
+ assert.equal(first.tool_calls[0].call_id, 'call_abc');
70
+ const second = await adapter.continueWithToolOutputs({
71
+ model: 'gpt-6-astra', previousResponseId: first.response_id, reasoningEffort: 'low', serviceTier: 'default',
72
+ toolOutputs: [{ call_id: first.tool_calls[0].call_id, output: { ok: true } }],
73
+ });
74
+ assert.equal(bodies[1].previous_response_id, 'resp_calls');
75
+ assert.deepEqual(bodies[1].input, [{ type: 'function_call_output', call_id: 'call_abc', output: '{"ok":true}' }]);
76
+ assert.equal(second.output_text, 'complete');
77
+ });
78
+
79
+ test('compaction is an explicit provider operation and has no arbitrary default wall-clock timeout', async () => {
80
+ let signal = 'unset';
81
+ let body = null;
82
+ const adapter = createOpenAIResponsesAdapter({
83
+ apiKey: 'test-key',
84
+ fetchImpl: async (url, options) => {
85
+ assert.equal(url, 'https://api.openai.com/v1/responses/compact');
86
+ signal = options.signal;
87
+ body = JSON.parse(options.body);
88
+ return jsonResponse({ id: 'cmp_1', object: 'response.compaction', output: [{ type: 'compaction', encrypted_content: 'opaque' }], usage: { input_tokens: 1_000, output_tokens: 100 } });
89
+ },
90
+ });
91
+ const compacted = await adapter.compact({ model: 'gpt-6-astra', previousResponseId: 'resp_previous' });
92
+ assert.equal(signal, undefined);
93
+ assert.equal(body.previous_response_id, 'resp_previous');
94
+ assert.equal(compacted.compaction_id, 'cmp_1');
95
+ });
@@ -0,0 +1,81 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import test from 'node:test';
7
+ import {
8
+ createProjectManifest,
9
+ getDefaultProfile,
10
+ getDefaultRuntime,
11
+ getDeployTarget,
12
+ getLocalDatabasePath,
13
+ getProjectName,
14
+ getProjectPreset,
15
+ getProjectRulesPath,
16
+ getRepositoryId,
17
+ portableRepositoryIdFromGit,
18
+ } from '../src/lib/project-config.js';
19
+
20
+ function git(cwd, args) {
21
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
22
+ }
23
+
24
+ test('portable project manifest is small and excludes runtime/account history', () => {
25
+ const config = createProjectManifest({
26
+ projectName: 'demo',
27
+ preset: 'cms',
28
+ profile: 'cms',
29
+ runTarget: 'local',
30
+ sdkVersion: '2.5.0',
31
+ repositoryId: 'github:owner/demo',
32
+ });
33
+
34
+ assert.equal(config.schema_version, 2);
35
+ assert.deepEqual(config.project, { name: 'demo' });
36
+ assert.equal(config.repository.id, 'github:owner/demo');
37
+ assert.equal(config.product.preset, 'cms');
38
+ assert.equal(config.defaults.profile, 'cms');
39
+ assert.equal(config.defaults.runtime, 'local');
40
+ assert.equal(config.defaults.model, 'auto');
41
+ assert.deepEqual(config.merkle, { enabled: true, semantic: true, persistence: 'auto' });
42
+ assert.equal(config.rules.file, '.agentsamrules');
43
+ assert.equal(getProjectRulesPath(config), '.agentsamrules');
44
+ assert.equal(config.knowledge.config, '.agentsam/knowledge.json');
45
+ assert.equal(config.local.database, '.agentsam/data/agentsam.sqlite');
46
+
47
+ const forbidden = [
48
+ 'account_id', 'user_id', 'tenant_id', 'workspace_id', 'current_run_id', 'plan_id', 'task_id',
49
+ 'connection_id', 'runtime_lease_id', 'latest_merkle_root', 'root_hash', 'model_history',
50
+ 'subagent_runs', 'pty_port', 'dev_port', 'deployed_at', 'cloudflare',
51
+ ];
52
+ const serialized = JSON.stringify(config);
53
+ for (const field of forbidden) assert.equal(serialized.includes(`"${field}"`), false, field);
54
+ });
55
+
56
+ test('project config accessors keep legacy flat projects readable during migration', () => {
57
+ const legacy = {
58
+ project: 'legacy-demo',
59
+ lane: 'data',
60
+ agent: 'data',
61
+ run_target: 'local',
62
+ deploy_target: 'gcp',
63
+ db_path: '.agentsam/old.sqlite',
64
+ };
65
+ assert.equal(getProjectName(legacy), 'legacy-demo');
66
+ assert.equal(getProjectPreset(legacy), 'data');
67
+ assert.equal(getDefaultProfile(legacy), 'data');
68
+ assert.equal(getDefaultRuntime(legacy), 'local');
69
+ assert.equal(getDeployTarget(legacy), 'gcp');
70
+ assert.equal(getLocalDatabasePath(legacy), '.agentsam/old.sqlite');
71
+ assert.equal(getRepositoryId(legacy), null);
72
+ assert.equal(getProjectRulesPath(legacy), '.agentsamrules');
73
+ });
74
+
75
+ test('GitHub remote becomes portable repository identity when adopting an existing repo', t => {
76
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-project-config-'));
77
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
78
+ git(root, ['init', '-q']);
79
+ git(root, ['remote', 'add', 'origin', 'git@github.com:ExampleOrg/DemoRepo.git']);
80
+ assert.equal(portableRepositoryIdFromGit(root), 'github:exampleorg/demorepo');
81
+ });
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { defaultProjectRules, findProjectRules, loadProjectRules } from '../src/lib/project-rules.js';
7
+
8
+ test('.agentsamrules behaves like a committed repo-level instruction file', t => {
9
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-rules-'));
10
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
11
+ const nested = path.join(root, 'src', 'nested');
12
+ fs.mkdirSync(nested, { recursive: true });
13
+ const content = defaultProjectRules('demo');
14
+ fs.writeFileSync(path.join(root, '.agentsamrules'), content);
15
+
16
+ assert.equal(findProjectRules(nested), path.join(root, '.agentsamrules'));
17
+ const loaded = loadProjectRules(nested);
18
+ assert.equal(loaded.found, true);
19
+ assert.equal(loaded.content, content);
20
+ assert.match(loaded.hash, /^sha256:[a-f0-9]{64}$/);
21
+ assert.equal(loaded.truncated, false);
22
+ });
23
+
24
+ test('project rules are deterministically bounded before entering context', t => {
25
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-rules-bound-'));
26
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
27
+ fs.writeFileSync(path.join(root, '.agentsamrules'), 'x'.repeat(200));
28
+ const loaded = loadProjectRules(root, { maxChars: 40 });
29
+ assert.equal(loaded.chars, 40);
30
+ assert.equal(loaded.source_chars, 200);
31
+ assert.equal(loaded.truncated, true);
32
+ });
33
+
34
+
35
+ test('project rules discovery never leaks across the repository boundary', t => {
36
+ const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-rules-boundary-'));
37
+ t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
38
+ fs.writeFileSync(path.join(parent, '.agentsamrules'), 'parent rules\n');
39
+ const repo = path.join(parent, 'repo');
40
+ const nested = path.join(repo, 'src');
41
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
42
+ fs.mkdirSync(nested, { recursive: true });
43
+ assert.equal(findProjectRules(nested), null);
44
+ });
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { describeProviderCredential, resolveProviderCredential } from '../src/lib/provider-credentials.js';
7
+
8
+ function fixtureHome(t) {
9
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-credentials-'));
10
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
11
+ fs.mkdirSync(path.join(home, '.agentsam', 'env.d'), { recursive: true });
12
+ return home;
13
+ }
14
+
15
+ test('provider credential resolver loads a secure AgentSam env file without exposing it in status', t => {
16
+ const home = fixtureHome(t);
17
+ const filename = path.join(home, '.agentsam', 'env.d', 'openai.env');
18
+ fs.writeFileSync(filename, 'export OPENAI_API_KEY="secret-from-file"\n', { mode: 0o600 });
19
+ if (process.platform !== 'win32') fs.chmodSync(filename, 0o600);
20
+
21
+ const resolved = resolveProviderCredential('openai', { env: {}, home });
22
+ assert.equal(resolved.configured, true);
23
+ assert.equal(resolved.source, 'agentsam_env_file');
24
+ assert.equal(resolved.value, 'secret-from-file');
25
+
26
+ const safe = describeProviderCredential('openai', { env: {}, home });
27
+ assert.equal(safe.configured, true);
28
+ assert.equal(safe.source, 'agentsam_env_file');
29
+ assert.equal(Object.hasOwn(safe, 'value'), false);
30
+ assert.doesNotMatch(JSON.stringify(safe), /secret-from-file/);
31
+ });
32
+
33
+ test('environment credential wins over disk fallback', t => {
34
+ const home = fixtureHome(t);
35
+ const filename = path.join(home, '.agentsam', 'env.d', 'openai.env');
36
+ fs.writeFileSync(filename, 'OPENAI_API_KEY=file-value\n', { mode: 0o600 });
37
+ if (process.platform !== 'win32') fs.chmodSync(filename, 0o600);
38
+ const resolved = resolveProviderCredential('openai', { env: { OPENAI_API_KEY: 'env-value' }, home });
39
+ assert.equal(resolved.source, 'environment');
40
+ assert.equal(resolved.value, 'env-value');
41
+ });
42
+
43
+ test('AgentSam refuses provider credential files with broad POSIX permissions', { skip: process.platform === 'win32' }, t => {
44
+ const home = fixtureHome(t);
45
+ const filename = path.join(home, '.agentsam', 'env.d', 'openai.env');
46
+ fs.writeFileSync(filename, 'OPENAI_API_KEY=unsafe-value\n', { mode: 0o644 });
47
+ fs.chmodSync(filename, 0o644);
48
+ const resolved = resolveProviderCredential('openai', { env: {}, home });
49
+ assert.equal(resolved.configured, false);
50
+ assert.equal(resolved.error, 'permissions_too_open');
51
+ assert.equal(resolved.value, '');
52
+ });
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import test from 'node:test';
4
+ import { compactConsumedToolResult, rehydrateContextRef } from '../src/context/index.js';
5
+
6
+ const sha = value => `sha256:${createHash('sha256').update(value).digest('hex')}`;
7
+
8
+ test('large consumed evidence can leave active context and later rehydrate by stable ref/hash', async () => {
9
+ const source = 'important evidence '.repeat(800);
10
+ const ref = 'tool:call_123';
11
+ const digest = sha(source);
12
+ const compacted = compactConsumedToolResult(source, { ref, hash: digest });
13
+ assert.equal(compacted.compacted, true);
14
+ assert.equal(compacted.chars, 4_000);
15
+ assert.equal(compacted.source_chars, source.length);
16
+ const adapter = { async read(requested) { assert.equal(requested, ref); return { content: source, hash: digest, kind: 'tool_result' }; } };
17
+ const restored = await rehydrateContextRef(ref, adapter, { expectedHash: digest, kind: 'tool_result', maxChars: 64_000 });
18
+ assert.equal(restored.content, source);
19
+ assert.equal(restored.hash, digest);
20
+ assert.equal(restored.truncated, false);
21
+ });
22
+
23
+ test('rehydration fails closed when stable source hash changed', async () => {
24
+ await assert.rejects(() => rehydrateContextRef('file:x', async () => ({ content: 'changed' }), { expectedHash: sha('original') }), /rehydration_hash_mismatch/);
25
+ });