@harperfast/harper 5.2.0-alpha.5 → 5.2.0-alpha.6

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 (173) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dist/agent/agent.d.ts +24 -3
  28. package/dist/agent/agent.js +172 -10
  29. package/dist/agent/agent.js.map +1 -1
  30. package/dist/agent/bestPractices.d.ts +24 -0
  31. package/dist/agent/bestPractices.js +60 -0
  32. package/dist/agent/bestPractices.js.map +1 -0
  33. package/dist/agent/mcpTools.d.ts +24 -0
  34. package/dist/agent/mcpTools.js +115 -0
  35. package/dist/agent/mcpTools.js.map +1 -0
  36. package/dist/agent/operations.js +10 -1
  37. package/dist/agent/operations.js.map +1 -1
  38. package/dist/agent/registryTools.d.ts +45 -0
  39. package/dist/agent/registryTools.js +113 -0
  40. package/dist/agent/registryTools.js.map +1 -0
  41. package/dist/agent/session.js +16 -7
  42. package/dist/agent/session.js.map +1 -1
  43. package/dist/agent/tools/fsTools.js +70 -28
  44. package/dist/agent/tools/fsTools.js.map +1 -1
  45. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  46. package/dist/agent/tools/inspectorTool.js +428 -0
  47. package/dist/agent/tools/inspectorTool.js.map +1 -0
  48. package/dist/agent/toolset.d.ts +16 -6
  49. package/dist/agent/toolset.js +17 -7
  50. package/dist/agent/toolset.js.map +1 -1
  51. package/dist/agent/types.d.ts +2 -0
  52. package/dist/components/Application.d.ts +73 -18
  53. package/dist/components/Application.js +475 -64
  54. package/dist/components/Application.js.map +1 -1
  55. package/dist/components/OptionsWatcher.d.ts +1 -1
  56. package/dist/components/OptionsWatcher.js +65 -2
  57. package/dist/components/OptionsWatcher.js.map +1 -1
  58. package/dist/components/Scope.d.ts +8 -6
  59. package/dist/components/Scope.js +22 -6
  60. package/dist/components/Scope.js.map +1 -1
  61. package/dist/components/componentLoader.js +5 -1
  62. package/dist/components/componentLoader.js.map +1 -1
  63. package/dist/components/componentSecrets.d.ts +33 -7
  64. package/dist/components/componentSecrets.js +473 -59
  65. package/dist/components/componentSecrets.js.map +1 -1
  66. package/dist/components/deploymentRecorder.d.ts +2 -2
  67. package/dist/components/deploymentRecorder.js +1 -1
  68. package/dist/components/deploymentRecorder.js.map +1 -1
  69. package/dist/components/gitCredentialHelper.d.ts +1 -0
  70. package/dist/components/gitCredentialHelper.js +113 -0
  71. package/dist/components/gitCredentialHelper.js.map +1 -0
  72. package/dist/components/gitCredentialServer.d.ts +33 -0
  73. package/dist/components/gitCredentialServer.js +236 -0
  74. package/dist/components/gitCredentialServer.js.map +1 -0
  75. package/dist/components/mcp/tools/application.d.ts +19 -0
  76. package/dist/components/mcp/tools/application.js +180 -22
  77. package/dist/components/mcp/tools/application.js.map +1 -1
  78. package/dist/components/mcp/tools/operations.d.ts +13 -0
  79. package/dist/components/mcp/tools/operations.js +1 -0
  80. package/dist/components/mcp/tools/operations.js.map +1 -1
  81. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  82. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  83. package/dist/components/operations.js +23 -21
  84. package/dist/components/operations.js.map +1 -1
  85. package/dist/components/operationsValidation.js +84 -21
  86. package/dist/components/operationsValidation.js.map +1 -1
  87. package/dist/components/secretOperations.d.ts +33 -11
  88. package/dist/components/secretOperations.js +90 -26
  89. package/dist/components/secretOperations.js.map +1 -1
  90. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  91. package/dist/config/harperConfigEnvVars.js +95 -5
  92. package/dist/config/harperConfigEnvVars.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/resources/Resource.d.ts +19 -0
  97. package/dist/resources/Resource.js +93 -2
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/Table.d.ts +9 -0
  100. package/dist/resources/Table.js +256 -50
  101. package/dist/resources/Table.js.map +1 -1
  102. package/dist/resources/analytics/read.js +28 -25
  103. package/dist/resources/analytics/read.js.map +1 -1
  104. package/dist/resources/defineResource.d.ts +180 -0
  105. package/dist/resources/defineResource.js +505 -0
  106. package/dist/resources/defineResource.js.map +1 -0
  107. package/dist/resources/defineTable.d.ts +221 -0
  108. package/dist/resources/defineTable.js +227 -0
  109. package/dist/resources/defineTable.js.map +1 -0
  110. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  111. package/dist/resources/jsonSchemaTypes.js +12 -4
  112. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  113. package/dist/resources/openApi.js +69 -13
  114. package/dist/resources/openApi.js.map +1 -1
  115. package/dist/resources/search.js +6 -8
  116. package/dist/resources/search.js.map +1 -1
  117. package/dist/server/graphqlQuerying.js +4 -2
  118. package/dist/server/graphqlQuerying.js.map +1 -1
  119. package/dist/server/http.d.ts +12 -0
  120. package/dist/server/http.js +35 -15
  121. package/dist/server/http.js.map +1 -1
  122. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  123. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +23 -0
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/utility/errors/hdbError.d.ts +21 -0
  127. package/dist/utility/errors/hdbError.js +23 -1
  128. package/dist/utility/errors/hdbError.js.map +1 -1
  129. package/dist/validation/configValidator.js +22 -6
  130. package/dist/validation/configValidator.js.map +1 -1
  131. package/index.ts +33 -0
  132. package/npm-shrinkwrap.json +9197 -15066
  133. package/package.json +3 -1
  134. package/resources/DESIGN.md +42 -15
  135. package/resources/Resource.ts +99 -2
  136. package/resources/Table.ts +275 -71
  137. package/resources/analytics/read.ts +30 -25
  138. package/resources/defineResource.ts +651 -0
  139. package/resources/defineTable.ts +407 -0
  140. package/resources/jsonSchemaTypes.ts +12 -4
  141. package/resources/openApi.ts +68 -16
  142. package/resources/search.ts +5 -8
  143. package/server/graphqlQuerying.ts +4 -2
  144. package/server/http.ts +40 -16
  145. package/server/serverHelpers/serverUtilities.ts +19 -6
  146. package/server/serverHelpers/uwsServer.ts +24 -0
  147. package/studio/web/assets/{Chat-Cv_2paZE.js → Chat-CTjtL8Z4.js} +2 -2
  148. package/studio/web/assets/{Chat-Cv_2paZE.js.map → Chat-CTjtL8Z4.js.map} +1 -1
  149. package/studio/web/assets/{FloatingChat-CPyPIcVR.js → FloatingChat-CafHR4Ur.js} +4 -4
  150. package/studio/web/assets/{FloatingChat-CPyPIcVR.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
  151. package/studio/web/assets/{applications-C0jT2vdZ.js → applications-Buh_q0Vj.js} +2 -2
  152. package/studio/web/assets/{applications-C0jT2vdZ.js.map → applications-Buh_q0Vj.js.map} +1 -1
  153. package/studio/web/assets/{index-D_GKOkhn.js → index-0hXeECkS.js} +6 -6
  154. package/studio/web/assets/{index-D_GKOkhn.js.map → index-0hXeECkS.js.map} +1 -1
  155. package/studio/web/assets/{index.lazy-CAmCIA65.js → index.lazy-B00B7VBT.js} +4 -4
  156. package/studio/web/assets/{index.lazy-CAmCIA65.js.map → index.lazy-B00B7VBT.js.map} +1 -1
  157. package/studio/web/assets/{profile-CaF-4aZe.js → profile-Cg2wwYPn.js} +2 -2
  158. package/studio/web/assets/{profile-CaF-4aZe.js.map → profile-Cg2wwYPn.js.map} +1 -1
  159. package/studio/web/assets/{setComponentFile-DLW1DHCt.js → setComponentFile-DCaDIvyB.js} +2 -2
  160. package/studio/web/assets/{setComponentFile-DLW1DHCt.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
  161. package/studio/web/assets/{setup-TGCErIhq.js → setup-CAVcAQjK.js} +2 -2
  162. package/studio/web/assets/{setup-TGCErIhq.js.map → setup-CAVcAQjK.js.map} +1 -1
  163. package/studio/web/assets/{status-DG0Maoao.js → status-BRXorNdD.js} +2 -2
  164. package/studio/web/assets/{status-DG0Maoao.js.map → status-BRXorNdD.js.map} +1 -1
  165. package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js → swagger-ui-react-Dy1D62vO.js} +2 -2
  166. package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
  167. package/studio/web/assets/{tsMode-DJ6Sl24Q.js → tsMode-A8gbL74v.js} +2 -2
  168. package/studio/web/assets/{tsMode-DJ6Sl24Q.js.map → tsMode-A8gbL74v.js.map} +1 -1
  169. package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js → useEntityRestURL-cDodrVcQ.js} +2 -2
  170. package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
  171. package/studio/web/index.html +1 -1
  172. package/utility/errors/hdbError.ts +32 -0
  173. package/validation/configValidator.ts +23 -6
@@ -15,7 +15,7 @@ import { Readable, Transform, pipeline } from 'node:stream';
15
15
  import { databases } from '../resources/databases.ts';
16
16
  import { createBlob, isSaving, deleteBlob } from '../resources/blob.ts';
17
17
  import * as terms from '../utility/hdbTerms.ts';
18
- import type { RegistryAuthReference } from './secretOperations.ts';
18
+ import type { CredentialReference } from './secretOperations.ts';
19
19
  import { ClientError } from '../utility/errors/hdbError.ts';
20
20
  import { logger } from '../utility/logging/logger.ts';
21
21
  import { hostname } from 'node:os';
@@ -62,10 +62,11 @@ interface CreateOptions {
62
62
  user?: string;
63
63
  restart_mode?: 'immediate' | 'rolling' | null;
64
64
  rollback_of?: string | null;
65
- // Registry-auth in reference form (`{ registry, secret, scope? }`) never a literal token. Kept
66
- // so a rollback can re-resolve the private-registry credential from hdb_secret without the
67
- // operator re-supplying it. Null when the deploy used no auth or a no-custody transient token.
68
- registry_auth?: RegistryAuthReference[] | null;
65
+ // Deploy credentials in reference form (`{ registry, secret, scope? }` / `{ host, secret,
66
+ // username? }`) — never a literal token. Kept so a rollback can re-resolve the credential from
67
+ // hdb_secret without the operator re-supplying it. Null when the deploy used no credentials or a
68
+ // no-custody transient token.
69
+ credentials?: CredentialReference[] | null;
69
70
  emitter?: ProgressEmitter;
70
71
  }
71
72
 
@@ -103,7 +104,7 @@ export class DeploymentRecorder {
103
104
  completed_at: null,
104
105
  user: options.user ?? null,
105
106
  rollback_of: options.rollback_of ?? null,
106
- registry_auth: options.registry_auth ?? null,
107
+ credentials: options.credentials ?? null,
107
108
  error: null,
108
109
  };
109
110
  const recorder = new DeploymentRecorder(deploymentId, record);
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ // Credential helper for private git-reference deploys (#1792), executed by git as a child process.
4
+ //
5
+ // It holds no secret. The token lives only in the deploying Harper process's memory; this script
6
+ // asks for it over the per-deploy socket named by HARPER_GIT_CREDENTIAL_SOCKET (see
7
+ // gitCredentialServer.ts) and writes the answer to stdout for git to consume. Nothing is written to
8
+ // disk, and the token never appears in argv or in this process's environment.
9
+ //
10
+ // git invokes it two ways, so it answers both:
11
+ // - as a `credential.helper` (git >= 2.31, wired up via GIT_CONFIG_*): the operation
12
+ // (`get`/`store`/`erase`) is the last argument and the request is key=value lines on stdin.
13
+ // - as GIT_ASKPASS (every git version): the prompt is the first argument.
14
+ // Only `get` yields anything; `store`/`erase` are deliberate no-ops, since persisting the
15
+ // credential is exactly what this design exists to avoid.
16
+
17
+ const net = require('node:net');
18
+
19
+ const socketPath = process.env.HARPER_GIT_CREDENTIAL_SOCKET;
20
+
21
+ function ask(request) {
22
+ return new Promise((resolve, reject) => {
23
+ const socket = net.connect(socketPath);
24
+ socket.setEncoding('utf8');
25
+ let response = '';
26
+ socket.on('error', reject);
27
+ socket.on('connect', () => socket.end(JSON.stringify(request)));
28
+ socket.on('data', (chunk) => (response += chunk));
29
+ socket.on('close', () => {
30
+ try {
31
+ resolve(response ? JSON.parse(response) : {});
32
+ } catch (error) {
33
+ reject(error);
34
+ }
35
+ });
36
+ });
37
+ }
38
+
39
+ function readStdin() {
40
+ return new Promise((resolve, reject) => {
41
+ let input = '';
42
+ process.stdin.setEncoding('utf8');
43
+ process.stdin.on('data', (chunk) => (input += chunk));
44
+ process.stdin.on('error', reject);
45
+ process.stdin.on('end', () => resolve(input));
46
+ });
47
+ }
48
+
49
+ // git's credential protocol: `key=value` lines terminated by a blank line.
50
+ function parseCredentialRequest(input) {
51
+ const request = {};
52
+ for (const line of input.split('\n')) {
53
+ const separator = line.indexOf('=');
54
+ if (separator > 0) request[line.slice(0, separator)] = line.slice(separator + 1).replace(/\r$/, '');
55
+ }
56
+ return request;
57
+ }
58
+
59
+ /**
60
+ * Interpret a GIT_ASKPASS prompt, e.g. `Username for 'https://github.com': ` or
61
+ * `Password for 'https://x-access-token@github.com': `.
62
+ *
63
+ * The leading words are localized, so which of the two prompts this is has to be decided
64
+ * structurally rather than by matching English: git only asks for a password once it has a
65
+ * username, and it embeds that username in the URL it echoes back. Userinfo present therefore means
66
+ * the password prompt; absent means the username prompt.
67
+ */
68
+ function parseAskpassPrompt(prompt) {
69
+ const quoted = /'([^']+)'/.exec(prompt);
70
+ if (!quoted) return null;
71
+ // url.username can carry a malformed percent-encoding sequence (e.g. from a redirect or a
72
+ // misconfigured remote), which makes decodeURIComponent throw a URIError — keep that inside the
73
+ // same try as the URL parse so it can't crash this process instead of just declining to answer.
74
+ try {
75
+ const url = new URL(quoted[1]);
76
+ return {
77
+ field: url.username ? 'password' : 'username',
78
+ protocol: url.protocol.replace(/:$/, ''),
79
+ host: url.host,
80
+ username: url.username ? decodeURIComponent(url.username) : undefined,
81
+ };
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async function main() {
88
+ if (!socketPath) return; // no session for this spawn: stay silent so git falls through unauthenticated
89
+
90
+ const args = process.argv.slice(2);
91
+ const operation = args[args.length - 1];
92
+
93
+ if (operation === 'get') {
94
+ const request = parseCredentialRequest(await readStdin());
95
+ const credential = await ask({ protocol: request.protocol, host: request.host, username: request.username });
96
+ if (!credential.password) return;
97
+ process.stdout.write(`username=${credential.username}\npassword=${credential.password}\n`);
98
+ return;
99
+ }
100
+ if (operation === 'store' || operation === 'erase') return;
101
+
102
+ const prompt = parseAskpassPrompt(args[0] ?? '');
103
+ if (!prompt) return;
104
+ const credential = await ask(prompt);
105
+ if (!credential.password) return;
106
+ process.stdout.write(`${prompt.field === 'username' ? credential.username : credential.password}\n`);
107
+ }
108
+
109
+ main().catch((error) => {
110
+ // Never leak request detail to git's stderr, which npm surfaces in deploy output. Exiting
111
+ // non-zero makes git treat the credential as unavailable and fail (GIT_TERMINAL_PROMPT=0
112
+ // prevents it from falling back to an interactive prompt).
113
+ process.stderr.write(`harper git credential helper failed: ${error.message}\n`);
114
+ process.exitCode = 1;
115
+ });
@@ -0,0 +1,251 @@
1
+ // Per-deploy git credential channel for private git-reference deploys (#1792).
2
+ //
3
+ // npm shells out to git to resolve a `github:org/repo#semver:...` package, and a private repo makes
4
+ // that clone need a credential. Every obvious way to hand git one persists it: a URL with embedded
5
+ // userinfo lands in the package spec and the lockfile, a `credential.helper` or an `.npmrc` is a
6
+ // file, and an env var is readable by every descendant process.
7
+ //
8
+ // Instead the token stays in this process's memory and is served over a per-deploy Unix socket in a
9
+ // 0700 directory. git is pointed at gitCredentialHelper.js — a secret-free script that relays git's
10
+ // request over that socket — and the socket dies with the spawn that needed it. The token never
11
+ // reaches disk, argv, the package spec, the operation body, or the operations log.
12
+
13
+ import { createServer, type Server, type Socket } from 'node:net';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { mkdtemp, rm } from 'node:fs/promises';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { ClientError } from '../utility/errors/hdbError.ts';
19
+ import logger from '../utility/logging/harper_logger.ts';
20
+
21
+ // GitHub's convention for authenticating a PAT over HTTPS: any non-empty username works, and
22
+ // `x-access-token` is what its own tooling sends. GitLab wants `oauth2` and Bitbucket
23
+ // `x-token-auth`, so a credential entry can override it.
24
+ export const DEFAULT_GIT_USERNAME = 'x-access-token';
25
+
26
+ // The one variable that carries authority: without it gitCredentialHelper.js cannot reach a session
27
+ // and answers nothing, so stripping it from a spawn's environment fully disarms the helper even if
28
+ // GIT_ASKPASS/GIT_CONFIG_* were somehow inherited.
29
+ export const GIT_CREDENTIAL_SOCKET_ENV = 'HARPER_GIT_CREDENTIAL_SOCKET';
30
+
31
+ // Generous for git's key=value request (a few hundred bytes), small enough that a peer streaming
32
+ // junk cannot exhaust the heap.
33
+ const MAX_REQUEST_BYTES = 64 * 1024;
34
+
35
+ /** A git-host credential after resolution: a literal token, held in memory for one deploy. */
36
+ export interface ResolvedGitCredential {
37
+ host: string;
38
+ token: string;
39
+ username?: string;
40
+ }
41
+
42
+ export interface GitCredentialSession {
43
+ /** Environment to merge into the spawn that performs the git clone — and only that spawn. */
44
+ env: Record<string, string>;
45
+ close(): Promise<void>;
46
+ }
47
+
48
+ /** `https://github.com/` / `GitHub.com` / `github.com/` all identify the same host. */
49
+ export function normalizeGitHost(host: string): string {
50
+ return host
51
+ .trim()
52
+ .replace(/^[a-z0-9+.-]+:\/\//i, '')
53
+ .replace(/^\/\//, '')
54
+ .replace(/\/.*$/, '')
55
+ .replace(/^[^@]*@/, '')
56
+ .toLowerCase();
57
+ }
58
+
59
+ // A loopback remote never puts the credential on a network, so plaintext http to one is not a
60
+ // disclosure. Anything else must be encrypted.
61
+ function isLoopbackHost(host: string): boolean {
62
+ const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
63
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
64
+ }
65
+
66
+ function answerFor(credentials: Map<string, ResolvedGitCredential>, request: any) {
67
+ if (!request || typeof request !== 'object') return {};
68
+ const host = typeof request.host === 'string' ? normalizeGitHost(request.host) : '';
69
+ const credential = credentials.get(host);
70
+ // An unknown host is not an error: git also asks about public hosts, and answering nothing lets
71
+ // it proceed unauthenticated rather than failing the deploy.
72
+ if (!credential) return {};
73
+ // Never hand a token to a cleartext transport. git asks for `http://` credentials exactly as it
74
+ // asks for `https://` ones, so without this a `git+http://` package (or a remote downgraded by a
75
+ // redirect) would put the token on the wire in the clear.
76
+ const protocol = typeof request.protocol === 'string' ? request.protocol.toLowerCase() : '';
77
+ if (protocol && protocol !== 'https' && !isLoopbackHost(host)) {
78
+ logger.warn?.(`refusing to serve a git credential for '${host}' over ${protocol}: only https is allowed`);
79
+ return {};
80
+ }
81
+ // git's credential protocol is line-based (and askpass reads only the first line), so a token
82
+ // carrying a newline would be truncated or would inject protocol attributes. A literal token is
83
+ // already rejected by the op schema; one resolved from an hdb_secret row is not, so guard the
84
+ // write boundary — the same place the .npmrc writer guards (#1717).
85
+ if (/[\r\n\0]/.test(credential.token)) {
86
+ logger.warn?.(`git credential for '${host}' contains an illegal control character; refusing to serve it`);
87
+ return {};
88
+ }
89
+ // git only re-asks with a username once it has one; if it names a different user than the one
90
+ // this credential is for, the credential does not apply.
91
+ const username = credential.username ?? DEFAULT_GIT_USERNAME;
92
+ if (typeof request.username === 'string' && request.username && request.username !== username) return {};
93
+ return { username, password: credential.token };
94
+ }
95
+
96
+ // The `credential.helper` reset below only takes effect on git >= 2.31, where GIT_CONFIG_* is
97
+ // honored; older git ignores those variables entirely and would fall through to GIT_ASKPASS with an
98
+ // inherited helper chain still live — so a machine configured with `credential.helper=store` would
99
+ // write this token to ~/.git-credentials. There is no way to disable an inherited helper on those
100
+ // versions, so refuse to serve a credential at all rather than persist one behind the operator's back.
101
+ const MINIMUM_GIT_VERSION = [2, 31];
102
+ function assertGitSupportsConfigEnv(): void {
103
+ let version: string;
104
+ try {
105
+ version = execFileSync('git', ['--version'], { encoding: 'utf8' });
106
+ } catch (error) {
107
+ throw new ClientError(`git is required to deploy from a git reference, but could not be run: ${error}`);
108
+ }
109
+ const parsed = /(\d+)\.(\d+)/.exec(version);
110
+ if (!parsed) throw new ClientError(`Could not determine the git version from '${version.trim()}'`);
111
+ const [major, minor] = [Number(parsed[1]), Number(parsed[2])];
112
+ if (major < MINIMUM_GIT_VERSION[0] || (major === MINIMUM_GIT_VERSION[0] && minor < MINIMUM_GIT_VERSION[1])) {
113
+ throw new ClientError(
114
+ `git ${MINIMUM_GIT_VERSION.join('.')} or newer is required to deploy with a git-host credential ` +
115
+ `(found ${version.trim()}): older versions ignore GIT_CONFIG_*, so an inherited credential helper ` +
116
+ `could persist the token to disk.`
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Start a credential session for one deploy. Returns the environment that lets git reach it, which
123
+ * the caller must apply ONLY to the spawn doing the clone (`npm pack`), never to the `npm install`
124
+ * that follows — a transitive dependency's install script must not be able to ask for a credential
125
+ * that was granted for the top-level repository.
126
+ *
127
+ * Wiring, in order of preference:
128
+ * - `credential.helper` via GIT_CONFIG_* (git >= 2.31). Structured key=value protocol, no prompt
129
+ * parsing. Inherited helpers are reset to empty first, so a machine configured with
130
+ * `credential.helper=store` cannot write this token to ~/.git-credentials when git reports the
131
+ * successful authentication back to its helper chain.
132
+ * - GIT_ASKPASS, honored by every git version, as the fallback for git < 2.31 (where GIT_CONFIG_*
133
+ * is silently ignored). npm's own git wrapper sets `GIT_ASKPASS=echo`, but only when the
134
+ * variable is unset, so ours survives.
135
+ * GIT_TERMINAL_PROMPT=0 keeps git from falling back to a terminal prompt (which would hang a deploy
136
+ * rather than fail it) if neither path yields a credential.
137
+ */
138
+ export async function startGitCredentialSession(
139
+ credentials: ResolvedGitCredential[],
140
+ helperPath: string
141
+ ): Promise<GitCredentialSession> {
142
+ const byHost = new Map<string, ResolvedGitCredential>();
143
+ for (const credential of credentials) {
144
+ const host = normalizeGitHost(credential.host);
145
+ // Two entries for the same host in one deploy is operator error — the second silently wins here
146
+ // (and its sealed secret already overwrote the first's, sharing a derived name). Surface it.
147
+ if (byHost.has(host)) logger.warn?.(`multiple git credentials supplied for host '${host}'; using the last`);
148
+ byHost.set(host, credential);
149
+ }
150
+
151
+ // The credential's confinement rests on filesystem permissions: a 0700 directory means only this
152
+ // uid can reach the socket. A Windows named pipe has no equivalent guarantee — it is created with
153
+ // a default security descriptor that can leave it open to other local users, which would hand the
154
+ // token to any process on the box. Rather than offer a quietly weaker credential channel, fail
155
+ // closed and let the operator use a deploy path whose isolation we can actually state.
156
+ if (process.platform === 'win32') {
157
+ throw new ClientError(
158
+ 'git-host deploy credentials are not supported on Windows: the credential is served over a Unix ' +
159
+ 'domain socket, which has no Windows equivalent that can be confined to this process owner.'
160
+ );
161
+ }
162
+ // Fail before minting a socket if git is too old for the credential-helper reset to hold.
163
+ assertGitSupportsConfigEnv();
164
+ // mkdtemp creates the directory 0700, so only this uid can reach the socket inside it.
165
+ const socketDir = await mkdtemp(join(tmpdir(), 'harper-git-cred-'));
166
+ const socketPath = join(socketDir, 'credential.sock');
167
+
168
+ const connections = new Set<Socket>();
169
+ // allowHalfOpen: the helper half-closes after sending its request, and we must still be able to
170
+ // write the answer back.
171
+ const server: Server = createServer({ allowHalfOpen: true }, (connection) => {
172
+ connections.add(connection);
173
+ connection.on('close', () => connections.delete(connection));
174
+ // A helper that dies mid-request must not take the node down with an unhandled 'error'.
175
+ connection.on('error', (error) => logger.warn?.(`git credential connection failed: ${error.message}`));
176
+ connection.setEncoding('utf8');
177
+ let request = '';
178
+ connection.on('data', (chunk) => {
179
+ request += chunk;
180
+ // A real request is a few hundred bytes. Cap it so a peer that streams without ever closing
181
+ // cannot grow this buffer until the node runs out of heap.
182
+ if (request.length > MAX_REQUEST_BYTES) {
183
+ logger.warn?.('git credential request exceeded the maximum size; dropping the connection');
184
+ connection.destroy();
185
+ }
186
+ });
187
+ connection.on('end', () => {
188
+ let answer: object;
189
+ try {
190
+ answer = answerFor(byHost, JSON.parse(request));
191
+ } catch {
192
+ answer = {};
193
+ }
194
+ connection.end(JSON.stringify(answer));
195
+ });
196
+ });
197
+ server.on('error', (error) => logger.warn?.(`git credential server failed: ${error.message}`));
198
+
199
+ try {
200
+ await new Promise<void>((resolve, reject) => {
201
+ server.once('error', reject);
202
+ server.listen(socketPath, () => {
203
+ server.removeListener('error', reject);
204
+ resolve();
205
+ });
206
+ });
207
+ } catch (error) {
208
+ // listen() failed, so no session is returned and nothing will call close() — take the temp dir
209
+ // with us rather than leaving a 0700 directory behind on every failed deploy.
210
+ await rm(socketDir, { recursive: true, force: true }).catch(() => {});
211
+ throw error;
212
+ }
213
+
214
+ // Quoted so a Harper installed under a path with spaces still works: git runs both the askpass
215
+ // command and a `!`-prefixed credential helper through a shell.
216
+ const helperCommand = `"${process.execPath}" "${helperPath}"`;
217
+ // Append to any GIT_CONFIG_* the operator already set rather than overwriting theirs; ours come
218
+ // last, which is also where git takes precedence from.
219
+ const inheritedCount = Number.parseInt(process.env.GIT_CONFIG_COUNT ?? '', 10);
220
+ const base = Number.isInteger(inheritedCount) && inheritedCount > 0 ? inheritedCount : 0;
221
+ const env: Record<string, string> = {
222
+ [GIT_CREDENTIAL_SOCKET_ENV]: socketPath,
223
+ GIT_TERMINAL_PROMPT: '0',
224
+ GIT_ASKPASS: helperCommand,
225
+ GIT_CONFIG_COUNT: String(base + 2),
226
+ // An empty value resets git's credential helper list, dropping any inherited helper.
227
+ [`GIT_CONFIG_KEY_${base}`]: 'credential.helper',
228
+ [`GIT_CONFIG_VALUE_${base}`]: '',
229
+ [`GIT_CONFIG_KEY_${base + 1}`]: 'credential.helper',
230
+ [`GIT_CONFIG_VALUE_${base + 1}`]: `!${helperCommand}`,
231
+ };
232
+
233
+ return {
234
+ env,
235
+ async close() {
236
+ byHost.clear();
237
+ // destroy() synchronously fires each connection's 'close' listener, which deletes it from this
238
+ // same Set — iterate a snapshot so that mid-iteration mutation cannot skip a connection.
239
+ const connectionsSnapshot = Array.from(connections);
240
+ for (const connection of connectionsSnapshot) connection.destroy();
241
+ connections.clear();
242
+ await new Promise<void>((resolve) => server.close(() => resolve()));
243
+ try {
244
+ await rm(socketDir, { recursive: true, force: true });
245
+ } catch (error) {
246
+ // Called from a finally; a throw here would mask the deploy's own error.
247
+ logger.warn?.(`Failed to remove git credential socket dir ${socketDir}: ${(error as Error).message}`);
248
+ }
249
+ },
250
+ };
251
+ }