@harperfast/harper 5.2.0-alpha.4 → 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.
- package/agent/agent.ts +152 -10
- package/agent/bestPractices.ts +58 -0
- package/agent/mcpTools.ts +122 -0
- package/agent/operations.ts +10 -1
- package/agent/registryTools.ts +117 -0
- package/agent/session.ts +16 -7
- package/agent/tools/fsTools.ts +74 -27
- package/agent/tools/inspectorTool.ts +459 -0
- package/agent/toolset.ts +27 -7
- package/agent/types.ts +2 -0
- package/components/Application.ts +566 -85
- package/components/OptionsWatcher.ts +62 -2
- package/components/Scope.ts +31 -8
- package/components/componentLoader.ts +5 -1
- package/components/componentSecrets.ts +514 -55
- package/components/deploymentRecorder.ts +7 -6
- package/components/gitCredentialHelper.js +115 -0
- package/components/gitCredentialServer.ts +251 -0
- package/components/mcp/tools/application.ts +197 -23
- package/components/mcp/tools/operations.ts +1 -1
- package/components/mcp/tools/schemas/derive.ts +6 -2
- package/components/operations.js +21 -19
- package/components/operationsValidation.js +86 -21
- package/components/secretOperations.ts +110 -30
- package/config/harperConfigEnvVars.ts +90 -5
- package/config-root.schema.json +4 -0
- package/dist/agent/agent.d.ts +24 -3
- package/dist/agent/agent.js +172 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/bestPractices.d.ts +24 -0
- package/dist/agent/bestPractices.js +60 -0
- package/dist/agent/bestPractices.js.map +1 -0
- package/dist/agent/mcpTools.d.ts +24 -0
- package/dist/agent/mcpTools.js +115 -0
- package/dist/agent/mcpTools.js.map +1 -0
- package/dist/agent/operations.js +10 -1
- package/dist/agent/operations.js.map +1 -1
- package/dist/agent/registryTools.d.ts +45 -0
- package/dist/agent/registryTools.js +113 -0
- package/dist/agent/registryTools.js.map +1 -0
- package/dist/agent/session.js +16 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/tools/fsTools.js +70 -28
- package/dist/agent/tools/fsTools.js.map +1 -1
- package/dist/agent/tools/inspectorTool.d.ts +40 -0
- package/dist/agent/tools/inspectorTool.js +428 -0
- package/dist/agent/tools/inspectorTool.js.map +1 -0
- package/dist/agent/toolset.d.ts +16 -6
- package/dist/agent/toolset.js +17 -7
- package/dist/agent/toolset.js.map +1 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/components/Application.d.ts +73 -18
- package/dist/components/Application.js +475 -64
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +1 -1
- package/dist/components/OptionsWatcher.js +65 -2
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.d.ts +8 -6
- package/dist/components/Scope.js +22 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +5 -1
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/componentSecrets.d.ts +33 -7
- package/dist/components/componentSecrets.js +473 -59
- package/dist/components/componentSecrets.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +2 -2
- package/dist/components/deploymentRecorder.js +1 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/gitCredentialHelper.d.ts +1 -0
- package/dist/components/gitCredentialHelper.js +113 -0
- package/dist/components/gitCredentialHelper.js.map +1 -0
- package/dist/components/gitCredentialServer.d.ts +33 -0
- package/dist/components/gitCredentialServer.js +236 -0
- package/dist/components/gitCredentialServer.js.map +1 -0
- package/dist/components/mcp/tools/application.d.ts +19 -0
- package/dist/components/mcp/tools/application.js +180 -22
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.d.ts +13 -0
- package/dist/components/mcp/tools/operations.js +1 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/derive.js +6 -2
- package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
- package/dist/components/operations.js +23 -21
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +84 -21
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/secretOperations.d.ts +33 -11
- package/dist/components/secretOperations.js +90 -26
- package/dist/components/secretOperations.js.map +1 -1
- package/dist/config/harperConfigEnvVars.d.ts +21 -0
- package/dist/config/harperConfigEnvVars.js +95 -5
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/Resource.d.ts +19 -0
- package/dist/resources/Resource.js +93 -2
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.d.ts +9 -0
- package/dist/resources/Table.js +256 -50
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/read.js +28 -25
- package/dist/resources/analytics/read.js.map +1 -1
- package/dist/resources/defineResource.d.ts +180 -0
- package/dist/resources/defineResource.js +505 -0
- package/dist/resources/defineResource.js.map +1 -0
- package/dist/resources/defineTable.d.ts +221 -0
- package/dist/resources/defineTable.js +227 -0
- package/dist/resources/defineTable.js.map +1 -0
- package/dist/resources/jsonSchemaTypes.d.ts +2 -0
- package/dist/resources/jsonSchemaTypes.js +12 -4
- package/dist/resources/jsonSchemaTypes.js.map +1 -1
- package/dist/resources/openApi.js +69 -13
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/search.js +6 -8
- package/dist/resources/search.js.map +1 -1
- package/dist/server/graphqlQuerying.js +4 -2
- package/dist/server/graphqlQuerying.js.map +1 -1
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +35 -15
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +8 -5
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +23 -0
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +21 -0
- package/dist/utility/errors/hdbError.js +23 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/validation/configValidator.js +22 -6
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +33 -0
- package/npm-shrinkwrap.json +9197 -15236
- package/package.json +5 -3
- package/resources/DESIGN.md +42 -15
- package/resources/Resource.ts +99 -2
- package/resources/Table.ts +275 -71
- package/resources/analytics/read.ts +30 -25
- package/resources/defineResource.ts +651 -0
- package/resources/defineTable.ts +407 -0
- package/resources/jsonSchemaTypes.ts +12 -4
- package/resources/openApi.ts +68 -16
- package/resources/search.ts +5 -8
- package/server/graphqlQuerying.ts +4 -2
- package/server/http.ts +40 -16
- package/server/serverHelpers/serverUtilities.ts +19 -6
- package/server/serverHelpers/uwsServer.ts +24 -0
- package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
- package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
- package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
- package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
- package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
- package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
- package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
- package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
- package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
- package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
- package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
- package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
- package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
- package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
- package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/errors/hdbError.ts +32 -0
- 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 {
|
|
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
|
-
//
|
|
66
|
-
// so a rollback can re-resolve the
|
|
67
|
-
// operator re-supplying it. Null when the deploy used no
|
|
68
|
-
|
|
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
|
-
|
|
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
|
+
}
|