dsh-git-branch-manage 1.0.8 → 1.0.10
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/lib/credentials.d.ts +7 -3
- package/lib/credentials.js +7 -3
- package/lib/helpers/git-credential-bridge.d.ts +32 -0
- package/lib/helpers/git-credential-bridge.js +114 -0
- package/lib/index.js +19 -0
- package/lib/network-tool.d.ts +111 -0
- package/lib/network-tool.js +250 -0
- package/lib/routes.d.ts +3 -0
- package/lib/routes.js +20 -0
- package/lib/shell-bridge.d.ts +93 -0
- package/lib/shell-bridge.js +121 -0
- package/package.json +1 -1
package/lib/credentials.d.ts
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
* and the store is read/written by the host alone.
|
|
8
8
|
*
|
|
9
9
|
* Authentication rides `git -c http.https://<host>/.extraHeader=…` on the
|
|
10
|
-
* panel's own network commands: the user's
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* panel's own network commands and on the `git_network` tool: the user's git
|
|
11
|
+
* configuration FILES are never written. Plain `git` commands in a spawned
|
|
12
|
+
* shell are covered separately by the environment bridge in `shell-bridge.ts`,
|
|
13
|
+
* which installs this store's per-host tokens as URL-scoped credential helpers
|
|
14
|
+
* through `GIT_CONFIG_*` (process environment only, never a config file).
|
|
15
|
+
* Only `https://` remotes receive the header; ssh and plain-http remotes are
|
|
16
|
+
* never sent a secret.
|
|
13
17
|
*/
|
|
14
18
|
import type { CredentialMethod } from './wire.js';
|
|
15
19
|
/**
|
package/lib/credentials.js
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
* and the store is read/written by the host alone.
|
|
8
8
|
*
|
|
9
9
|
* Authentication rides `git -c http.https://<host>/.extraHeader=…` on the
|
|
10
|
-
* panel's own network commands: the user's
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* panel's own network commands and on the `git_network` tool: the user's git
|
|
11
|
+
* configuration FILES are never written. Plain `git` commands in a spawned
|
|
12
|
+
* shell are covered separately by the environment bridge in `shell-bridge.ts`,
|
|
13
|
+
* which installs this store's per-host tokens as URL-scoped credential helpers
|
|
14
|
+
* through `GIT_CONFIG_*` (process environment only, never a config file).
|
|
15
|
+
* Only `https://` remotes receive the header; ssh and plain-http remotes are
|
|
16
|
+
* never sent a secret.
|
|
13
17
|
*/
|
|
14
18
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
15
19
|
import { dirname, join } from 'node:path';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone git credential helper shipped with the dsh-git-branch plugin.
|
|
3
|
+
*
|
|
4
|
+
* The panel's credential file (`~/.dsh/git-credentials.json`) normally rides
|
|
5
|
+
* `-c http.<url>.extraHeader` config on the routes and the `git_network` tool.
|
|
6
|
+
* This helper extends that coverage to a plain shell `git push`: the plugin's
|
|
7
|
+
* host process exports `GIT_CONFIG_COUNT/KEY/VALUE` entries that install this
|
|
8
|
+
* script as a URL-scoped credential helper for every stored host, so commands
|
|
9
|
+
* the model runs in the shell authenticate as the configured account too.
|
|
10
|
+
*
|
|
11
|
+
* Design constraints (see docs/decisions/0029 and 0032):
|
|
12
|
+
* - The user's global git config and credential helpers are never written;
|
|
13
|
+
* the injection lives in the process environment only.
|
|
14
|
+
* - The token never enters the conversation: this script reads the
|
|
15
|
+
* credential file directly on the host and answers git on stdout.
|
|
16
|
+
* - URL scope (`credential.https://<host>/.helper`) confines the helper to
|
|
17
|
+
* hosts with a stored credential. The host half also clears the machine
|
|
18
|
+
* helper list, so a host this script cannot answer fails fast instead of
|
|
19
|
+
* opening an interactive credential dialog.
|
|
20
|
+
* - `get` is silent on a miss (git falls through to the next helper);
|
|
21
|
+
* `store`/`erase` are pass-through by decision — writing back would race
|
|
22
|
+
* the panel's atomic save and leak the file shape.
|
|
23
|
+
*
|
|
24
|
+
* Environment contract (set by src/shell-bridge.ts):
|
|
25
|
+
* - `DSH_GIT_BRIDGE_HELPER` — credential file path (required).
|
|
26
|
+
* - `DSH_GIT_BRIDGE_HOME` — user home directory (fallback resolution).
|
|
27
|
+
* - `DSH_GIT_BRIDGE_DSH_HOME` — `$DSH_HOME` override (optional).
|
|
28
|
+
* - `DSH_GIT_BRIDGE_DEBUG` — when non-blank, a debug log path (optional).
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-git-branch/git-credential-bridge
|
|
31
|
+
*/
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone git credential helper shipped with the dsh-git-branch plugin.
|
|
3
|
+
*
|
|
4
|
+
* The panel's credential file (`~/.dsh/git-credentials.json`) normally rides
|
|
5
|
+
* `-c http.<url>.extraHeader` config on the routes and the `git_network` tool.
|
|
6
|
+
* This helper extends that coverage to a plain shell `git push`: the plugin's
|
|
7
|
+
* host process exports `GIT_CONFIG_COUNT/KEY/VALUE` entries that install this
|
|
8
|
+
* script as a URL-scoped credential helper for every stored host, so commands
|
|
9
|
+
* the model runs in the shell authenticate as the configured account too.
|
|
10
|
+
*
|
|
11
|
+
* Design constraints (see docs/decisions/0029 and 0032):
|
|
12
|
+
* - The user's global git config and credential helpers are never written;
|
|
13
|
+
* the injection lives in the process environment only.
|
|
14
|
+
* - The token never enters the conversation: this script reads the
|
|
15
|
+
* credential file directly on the host and answers git on stdout.
|
|
16
|
+
* - URL scope (`credential.https://<host>/.helper`) confines the helper to
|
|
17
|
+
* hosts with a stored credential. The host half also clears the machine
|
|
18
|
+
* helper list, so a host this script cannot answer fails fast instead of
|
|
19
|
+
* opening an interactive credential dialog.
|
|
20
|
+
* - `get` is silent on a miss (git falls through to the next helper);
|
|
21
|
+
* `store`/`erase` are pass-through by decision — writing back would race
|
|
22
|
+
* the panel's atomic save and leak the file shape.
|
|
23
|
+
*
|
|
24
|
+
* Environment contract (set by src/shell-bridge.ts):
|
|
25
|
+
* - `DSH_GIT_BRIDGE_HELPER` — credential file path (required).
|
|
26
|
+
* - `DSH_GIT_BRIDGE_HOME` — user home directory (fallback resolution).
|
|
27
|
+
* - `DSH_GIT_BRIDGE_DSH_HOME` — `$DSH_HOME` override (optional).
|
|
28
|
+
* - `DSH_GIT_BRIDGE_DEBUG` — when non-blank, a debug log path (optional).
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-git-branch/git-credential-bridge
|
|
31
|
+
*/
|
|
32
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
/** git helpers receive exactly one argument: the operation. */
|
|
36
|
+
const OPERATION = process.argv[2] ?? 'get';
|
|
37
|
+
/** The credential file this helper reads (`DSH_GIT_BRIDGE_HELPER` wins). */
|
|
38
|
+
const DEFAULT_CREDENTIAL_FILE = 'git-credentials.json';
|
|
39
|
+
/** The dsh home directory below the user profile when `$DSH_HOME` is unset. */
|
|
40
|
+
const PACKAGE_HOME = '.dsh';
|
|
41
|
+
/** The Basic-auth name a token without a username is sent under. */
|
|
42
|
+
const TOKEN_USERNAME = 'oauth2';
|
|
43
|
+
/**
|
|
44
|
+
* Append one debug line behind the user's back; a broken log path must never
|
|
45
|
+
* break the credential lookup.
|
|
46
|
+
*/
|
|
47
|
+
function debug(line) {
|
|
48
|
+
const path = process.env.DSH_GIT_BRIDGE_DEBUG;
|
|
49
|
+
if (path === undefined || path.trim() === '')
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
appendFileSync(path, `${line}\n`, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Deliberately swallowed: debugging is best-effort.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The credential file this helper reads (`DSH_GIT_BRIDGE_HELPER` wins). */
|
|
59
|
+
function credentialFile() {
|
|
60
|
+
const helper = process.env.DSH_GIT_BRIDGE_HELPER;
|
|
61
|
+
if (helper !== undefined && helper.trim() !== '')
|
|
62
|
+
return helper;
|
|
63
|
+
const dshHome = process.env.DSH_GIT_BRIDGE_DSH_HOME;
|
|
64
|
+
const home = dshHome !== undefined && dshHome.trim() !== ''
|
|
65
|
+
? dshHome
|
|
66
|
+
: process.env.DSH_GIT_BRIDGE_HOME ?? homedir();
|
|
67
|
+
return join(home, DEFAULT_CREDENTIAL_FILE);
|
|
68
|
+
}
|
|
69
|
+
/** One `key=value` request from git on stdin. */
|
|
70
|
+
function readRequest() {
|
|
71
|
+
const text = readFileSync(0, 'utf8');
|
|
72
|
+
const request = new Map();
|
|
73
|
+
for (const line of text.split('\n')) {
|
|
74
|
+
const at = line.indexOf('=');
|
|
75
|
+
if (at > 0)
|
|
76
|
+
request.set(line.slice(0, at), line.slice(at + 1));
|
|
77
|
+
}
|
|
78
|
+
return request;
|
|
79
|
+
}
|
|
80
|
+
/** The stored credential for one host, or undefined when there is none. */
|
|
81
|
+
function readCredential(host) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(readFileSync(credentialFile(), 'utf8'));
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
debug(`read failed: ${String(error)}`);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const credentials = parsed.credentials;
|
|
91
|
+
if (!Array.isArray(credentials))
|
|
92
|
+
return undefined;
|
|
93
|
+
const entry = credentials.find((item) => typeof item === 'object' && item !== null && item.host === host);
|
|
94
|
+
if (entry === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
const secret = typeof entry.secret === 'string' ? entry.secret : '';
|
|
97
|
+
if (secret === '')
|
|
98
|
+
return undefined;
|
|
99
|
+
const username = typeof entry.username === 'string' && entry.username.trim() !== ''
|
|
100
|
+
? entry.username
|
|
101
|
+
: TOKEN_USERNAME;
|
|
102
|
+
return { username, secret };
|
|
103
|
+
}
|
|
104
|
+
if (OPERATION === 'get') {
|
|
105
|
+
const request = readRequest();
|
|
106
|
+
const host = request.get('host') ?? '';
|
|
107
|
+
debug(`get host=${host} protocol=${request.get('protocol') ?? ''}`);
|
|
108
|
+
const credential = readCredential(host);
|
|
109
|
+
if (credential !== undefined) {
|
|
110
|
+
process.stdout.write(`username=${credential.username}\npassword=${credential.secret}\n`);
|
|
111
|
+
}
|
|
112
|
+
// A miss stays silent: git tries the next helper in the chain.
|
|
113
|
+
}
|
|
114
|
+
// `store` / `erase` are deliberate no-ops (see the header comment).
|
package/lib/index.js
CHANGED
|
@@ -11,7 +11,9 @@ import { childProcessExec } from './git.js';
|
|
|
11
11
|
import { ACCEL_DEFAULT_URL, accelerateUrlError } from './normalize.js';
|
|
12
12
|
import { handleCredentials, handleCredentialsDelete, handleCredentialsOAuthPoll, handleCredentialsOAuthStart, handleCredentialsSave, handleRemote, handleRemotes, handleAiCommitMessage, handleChanges, handleCommit, handleCommitFiles, handleCreateBranch, handleCreateTag, handleCreateWorktree, handleDeleteBranch, handleEnsureDirectory, handleFetch, handleGroupWorktrees, handleInspectWorktree, handleLog, handlePathExists, handlePurgeDirectory, handlePushTag, handleRemoveWorktree, handleRenameBranch, handleStage, handleStatus, handleSwitch, handleUpdate, handlePush, handleWorktreesAll, } from './routes.js';
|
|
13
13
|
import { validateRootDir } from './settings.js';
|
|
14
|
+
import { gitNetworkTool } from './network-tool.js';
|
|
14
15
|
import { gitCredentialsTool } from './tool.js';
|
|
16
|
+
import { createShellBridge } from './shell-bridge.js';
|
|
15
17
|
import { ROUTE_AI_COMMIT_MESSAGE, ROUTE_BRANCH, ROUTE_BRANCH_DELETE, ROUTE_BRANCH_RENAME, ROUTE_CHANGES, ROUTE_COMMIT, ROUTE_COMMIT_FILES, ROUTE_CREDENTIALS, ROUTE_CREDENTIALS_DELETE, ROUTE_CREDENTIALS_OAUTH_POLL, ROUTE_CREDENTIALS_OAUTH_START, ROUTE_CREDENTIALS_SAVE, ROUTE_ENSURE_DIRECTORY, ROUTE_EXISTS, ROUTE_FETCH, ROUTE_GROUP, ROUTE_INSPECT, ROUTE_LOG, ROUTE_PUSH, ROUTE_PURGE, ROUTE_REMOTE, ROUTE_REMOTES, ROUTE_REMOVE, ROUTE_STAGE, ROUTE_STATUS, ROUTE_SWITCH, ROUTE_TAG, ROUTE_TAG_PUSH, ROUTE_UPDATE, ROUTE_WORKTREE, ROUTE_WORKTREES_ALL } from './wire.js';
|
|
16
18
|
export const name = 'dsh-git-branch';
|
|
17
19
|
export const inject = [];
|
|
@@ -140,6 +142,14 @@ export function sectionOf(config) {
|
|
|
140
142
|
export function apply(ctx, config = {}) {
|
|
141
143
|
validateConfig(config);
|
|
142
144
|
const currentConfig = () => ctx.fiber?.config ?? config;
|
|
145
|
+
// Host half of the shell credential bridge: derive the environment that
|
|
146
|
+
// lets a plain `git push` in a spawned shell authenticate with the panel's
|
|
147
|
+
// stored token instead of the machine helper's account. It also clears the
|
|
148
|
+
// machine helper chain, so a host without a stored token fails fast rather
|
|
149
|
+
// than opening a credential dialog no agent shell can answer. Applied at
|
|
150
|
+
// activate and re-derived after every credential save / delete / sign-in.
|
|
151
|
+
const shellBridge = createShellBridge({ home: () => homedir(), envHome: () => process.env.DSH_HOME });
|
|
152
|
+
void shellBridge.apply();
|
|
143
153
|
const deps = () => ({
|
|
144
154
|
exec: childProcessExec,
|
|
145
155
|
sectionRootDir: () => readVolatile(currentConfig().rootDir),
|
|
@@ -149,6 +159,7 @@ export function apply(ctx, config = {}) {
|
|
|
149
159
|
sectionCnAccelerateUrl: () => readVolatile(currentConfig().cnAccelerateUrl),
|
|
150
160
|
home: () => homedir(),
|
|
151
161
|
envHome: () => process.env.DSH_HOME,
|
|
162
|
+
refreshShellBridge: () => shellBridge.apply(),
|
|
152
163
|
});
|
|
153
164
|
// In dsh 0.1.7+, the settings service projects volatile Config fields into forms.
|
|
154
165
|
// We configure auto: false to inform the host that this plugin provides its own
|
|
@@ -163,6 +174,14 @@ export function apply(ctx, config = {}) {
|
|
|
163
174
|
ctx.inject(['tools'], (toolsCtx) => {
|
|
164
175
|
const tools = toolsCtx.tools;
|
|
165
176
|
toolsCtx.effect(() => tools.register(gitCredentialsTool({ home: () => homedir(), envHome: () => process.env.DSH_HOME })), 'git-worktree: git_credentials tool');
|
|
177
|
+
// The panel routes inject the stored credentials into the requests the
|
|
178
|
+
// browser half makes; a shell command is served by the environment bridge
|
|
179
|
+
// (see `shell-bridge.ts`) instead. This tool is the deterministic path for
|
|
180
|
+
// a session: it runs one network subcommand with the `-c
|
|
181
|
+
// http.<url>.extraHeader` prefix, so the command authenticates as the
|
|
182
|
+
// configured account and the secret never enters the conversation — even
|
|
183
|
+
// when the bridge is missing (an older host, a shell outside the plugin).
|
|
184
|
+
toolsCtx.effect(() => tools.register(gitNetworkTool({ home: () => homedir(), envHome: () => process.env.DSH_HOME })), 'git-worktree: git_network tool');
|
|
166
185
|
});
|
|
167
186
|
ctx.inject(['webServer'], (webCtx) => {
|
|
168
187
|
/** Send one outcome as JSON; no-store because repo facts are point-in-time. */
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `git_network` tool (host half).
|
|
3
|
+
*
|
|
4
|
+
* The credentials panel stores one token per remote host and makes it work on
|
|
5
|
+
* two channels: the panel's own fetch / pull / push routes (`-c
|
|
6
|
+
* http.<url>.extraHeader=…`, see `authConfigArgs`) and the environment bridge
|
|
7
|
+
* the host installs for the shells it spawns (`shell-bridge.ts`). This tool is
|
|
8
|
+
* the third, explicit one: it runs ONE git network command with the same
|
|
9
|
+
* `authConfigArgs` the routes use, so a session reliably pushes as the account
|
|
10
|
+
* the user configured and the secret never enters the conversation — including
|
|
11
|
+
* on a host that predates the bridge.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately narrower than a shell wrapper: a fixed subcommand
|
|
14
|
+
* allow-list (fetch / pull / push / ls-remote), no force/delete refspecs, no
|
|
15
|
+
* `--upload-pack` / `--receive-pack` / `-c` options, and an absolute
|
|
16
|
+
* `repoPath` as cwd. Those exclusions are about blast radius, not about
|
|
17
|
+
* authentication: the tool exists to let a session reach the network with the
|
|
18
|
+
* configured credential, not to become a general command runner.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-git-branch/network-tool
|
|
21
|
+
*/
|
|
22
|
+
import { type CredentialStore, type GitCredential } from './credentials.js';
|
|
23
|
+
import { type Exec } from './git.js';
|
|
24
|
+
/** Model-facing tool name. */
|
|
25
|
+
export declare const GIT_NETWORK_TOOL_NAME = "git_network";
|
|
26
|
+
/** The only git subcommands this tool will run. */
|
|
27
|
+
export declare const GIT_NETWORK_SUBCOMMANDS: readonly ["fetch", "pull", "push", "ls-remote"];
|
|
28
|
+
/** Upper bound on the argument vector (the panel's own commands need far fewer). */
|
|
29
|
+
export declare const GIT_NETWORK_MAX_ARGS = 32;
|
|
30
|
+
/** The arguments this tool accepts. */
|
|
31
|
+
export interface GitNetworkArgs {
|
|
32
|
+
/** Absolute directory inside the target repository; the command's cwd. */
|
|
33
|
+
readonly repoPath?: string;
|
|
34
|
+
/** Git arguments, subcommand first (`['push', 'origin', 'main']`). */
|
|
35
|
+
readonly args?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
/** A validated request, ready to run. */
|
|
38
|
+
export interface GitNetworkRequest {
|
|
39
|
+
readonly repoPath: string;
|
|
40
|
+
readonly args: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
/** The canonical value `execute` returns. */
|
|
43
|
+
export interface GitNetworkValue {
|
|
44
|
+
/** Directory the command ran in (echoed for the transcript). */
|
|
45
|
+
readonly repoPath: string;
|
|
46
|
+
/** The git subcommand that ran. */
|
|
47
|
+
readonly subcommand: string;
|
|
48
|
+
/** git's exit status; a non-zero status is a normal result, not an error. */
|
|
49
|
+
readonly exitCode: number;
|
|
50
|
+
/** Standard output, secret-redacted and length-capped. */
|
|
51
|
+
readonly stdout: string;
|
|
52
|
+
/** Standard error, secret-redacted and length-capped. */
|
|
53
|
+
readonly stderr: string;
|
|
54
|
+
/** Hosts a credential was injected for (injected in full on every call). */
|
|
55
|
+
readonly authenticatedHosts: readonly string[];
|
|
56
|
+
/** Whether either stream was cut at the length cap. */
|
|
57
|
+
readonly truncated: boolean;
|
|
58
|
+
/** Why the credential store could not be read; the command then ran bare. */
|
|
59
|
+
readonly credentialsError?: string;
|
|
60
|
+
}
|
|
61
|
+
/** Host-side seams the tool reads through. */
|
|
62
|
+
export interface GitNetworkToolDeps {
|
|
63
|
+
/** User home directory seam. */
|
|
64
|
+
readonly home: () => string;
|
|
65
|
+
/** `$DSH_HOME` seam (blank/undefined = unset). */
|
|
66
|
+
readonly envHome: () => string | undefined;
|
|
67
|
+
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
68
|
+
readonly store?: CredentialStore;
|
|
69
|
+
/** Process seam; the real `git` over node:child_process when absent. */
|
|
70
|
+
readonly exec?: Exec;
|
|
71
|
+
}
|
|
72
|
+
/** A tool definition as `ctx.tools.register` accepts it. */
|
|
73
|
+
export interface GitNetworkTool {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
readonly description: string;
|
|
76
|
+
readonly parameters: Record<string, unknown>;
|
|
77
|
+
readonly output: {
|
|
78
|
+
readonly schema: Record<string, unknown>;
|
|
79
|
+
readonly render: (args: GitNetworkArgs, value: GitNetworkValue) => readonly {
|
|
80
|
+
readonly type: 'text';
|
|
81
|
+
readonly text: string;
|
|
82
|
+
}[];
|
|
83
|
+
};
|
|
84
|
+
readonly execute: (args: GitNetworkArgs, exec?: {
|
|
85
|
+
readonly signal?: AbortSignal;
|
|
86
|
+
}) => Promise<GitNetworkValue>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Why a request cannot run (undefined = fine). Pure and exhaustive so the
|
|
90
|
+
* tests can pin every rejection without spawning git.
|
|
91
|
+
* @param repoPath - the requested cwd (validated as an absolute path).
|
|
92
|
+
* @param args - the requested git argument vector (subcommand first).
|
|
93
|
+
*/
|
|
94
|
+
export declare function gitNetworkRequestIssue(repoPath: unknown, args: unknown): string | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Remove stored secrets — raw and in the Basic header's base64 form — from
|
|
97
|
+
* git's own diagnostics. git does not echo the Authorization header, but the
|
|
98
|
+
* redaction also covers a remote URL that embeds the token, and costs one
|
|
99
|
+
* pass per stored host.
|
|
100
|
+
* @param text - one captured stream.
|
|
101
|
+
* @param entries - the credentials that were injected.
|
|
102
|
+
*/
|
|
103
|
+
export declare function redactSecrets(text: string, entries: readonly GitCredential[]): string;
|
|
104
|
+
/** Compose the model-facing text of one result. */
|
|
105
|
+
export declare function renderGitNetwork(value: GitNetworkValue): string;
|
|
106
|
+
/**
|
|
107
|
+
* Build the `git_network` tool definition.
|
|
108
|
+
* @param deps - host seams (home/env, an optional store and an optional exec).
|
|
109
|
+
* @returns a definition ready for `ctx.tools.register`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function gitNetworkTool(deps: GitNetworkToolDeps): GitNetworkTool;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `git_network` tool (host half).
|
|
3
|
+
*
|
|
4
|
+
* The credentials panel stores one token per remote host and makes it work on
|
|
5
|
+
* two channels: the panel's own fetch / pull / push routes (`-c
|
|
6
|
+
* http.<url>.extraHeader=…`, see `authConfigArgs`) and the environment bridge
|
|
7
|
+
* the host installs for the shells it spawns (`shell-bridge.ts`). This tool is
|
|
8
|
+
* the third, explicit one: it runs ONE git network command with the same
|
|
9
|
+
* `authConfigArgs` the routes use, so a session reliably pushes as the account
|
|
10
|
+
* the user configured and the secret never enters the conversation — including
|
|
11
|
+
* on a host that predates the bridge.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately narrower than a shell wrapper: a fixed subcommand
|
|
14
|
+
* allow-list (fetch / pull / push / ls-remote), no force/delete refspecs, no
|
|
15
|
+
* `--upload-pack` / `--receive-pack` / `-c` options, and an absolute
|
|
16
|
+
* `repoPath` as cwd. Those exclusions are about blast radius, not about
|
|
17
|
+
* authentication: the tool exists to let a session reach the network with the
|
|
18
|
+
* configured credential, not to become a general command runner.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-git-branch/network-tool
|
|
21
|
+
*/
|
|
22
|
+
import { isAbsolute } from 'node:path';
|
|
23
|
+
import { authConfigArgs, basicUsername, createCredentialStore, credentialFilePath, } from './credentials.js';
|
|
24
|
+
import { childProcessExec, NETWORK_TIMEOUT_MS } from './git.js';
|
|
25
|
+
/** Model-facing tool name. */
|
|
26
|
+
export const GIT_NETWORK_TOOL_NAME = 'git_network';
|
|
27
|
+
/** The only git subcommands this tool will run. */
|
|
28
|
+
export const GIT_NETWORK_SUBCOMMANDS = ['fetch', 'pull', 'push', 'ls-remote'];
|
|
29
|
+
/** Upper bound on the argument vector (the panel's own commands need far fewer). */
|
|
30
|
+
export const GIT_NETWORK_MAX_ARGS = 32;
|
|
31
|
+
/** Upper bound on one argument token. */
|
|
32
|
+
const MAX_TOKEN_LENGTH = 512;
|
|
33
|
+
/** Output kept from each stream, in characters; git diagnostics are short. */
|
|
34
|
+
const MAX_OUTPUT_CHARS = 64 * 1024;
|
|
35
|
+
/**
|
|
36
|
+
* Global options that would either replace the injected credential, point
|
|
37
|
+
* git at another repository, or load another config. They belong BEFORE the
|
|
38
|
+
* subcommand in git's own grammar, so the allow-list below already rejects
|
|
39
|
+
* them; this set is defence in depth for arguments passed after it.
|
|
40
|
+
*/
|
|
41
|
+
const BLOCKED_FLAGS = new Set([
|
|
42
|
+
'-c',
|
|
43
|
+
'--config-env',
|
|
44
|
+
'--exec-path',
|
|
45
|
+
'--git-dir',
|
|
46
|
+
'--work-tree',
|
|
47
|
+
'--namespace',
|
|
48
|
+
'--bare',
|
|
49
|
+
'--no-replace-objects',
|
|
50
|
+
]);
|
|
51
|
+
/** Same defence for flags that take a value with `=`. */
|
|
52
|
+
const BLOCKED_PREFIXES = ['--config-env=', '--upload-pack', '--receive-pack', '--exec='];
|
|
53
|
+
/** `push` is the one subcommand where these change the remote irreversibly. */
|
|
54
|
+
const PUSH_BLOCKED_FLAGS = new Set([
|
|
55
|
+
'-f',
|
|
56
|
+
'--force',
|
|
57
|
+
'--force-with-lease',
|
|
58
|
+
'--force-if-includes',
|
|
59
|
+
'--mirror',
|
|
60
|
+
'--delete',
|
|
61
|
+
'-d',
|
|
62
|
+
'--prune',
|
|
63
|
+
]);
|
|
64
|
+
/**
|
|
65
|
+
* Why a request cannot run (undefined = fine). Pure and exhaustive so the
|
|
66
|
+
* tests can pin every rejection without spawning git.
|
|
67
|
+
* @param repoPath - the requested cwd (validated as an absolute path).
|
|
68
|
+
* @param args - the requested git argument vector (subcommand first).
|
|
69
|
+
*/
|
|
70
|
+
export function gitNetworkRequestIssue(repoPath, args) {
|
|
71
|
+
if (typeof repoPath !== 'string' || repoPath.trim() === '')
|
|
72
|
+
return '"repoPath" must be a non-empty string';
|
|
73
|
+
if (!isAbsolute(repoPath))
|
|
74
|
+
return '"repoPath" must be an absolute path';
|
|
75
|
+
if (!Array.isArray(args) || args.length === 0)
|
|
76
|
+
return '"args" must be a non-empty array of git arguments';
|
|
77
|
+
if (args.length > GIT_NETWORK_MAX_ARGS)
|
|
78
|
+
return `"args" may hold at most ${String(GIT_NETWORK_MAX_ARGS)} items`;
|
|
79
|
+
const tokens = [];
|
|
80
|
+
for (const item of args) {
|
|
81
|
+
if (typeof item !== 'string')
|
|
82
|
+
return '"args" must contain only strings';
|
|
83
|
+
if (item === '' || item.includes('\0'))
|
|
84
|
+
return '"args" must not contain empty or NUL tokens';
|
|
85
|
+
if (item.length > MAX_TOKEN_LENGTH)
|
|
86
|
+
return `one argument exceeds ${String(MAX_TOKEN_LENGTH)} characters`;
|
|
87
|
+
tokens.push(item);
|
|
88
|
+
}
|
|
89
|
+
const subcommand = tokens[0];
|
|
90
|
+
if (subcommand === undefined)
|
|
91
|
+
return '"args" must name a git subcommand first';
|
|
92
|
+
if (!GIT_NETWORK_SUBCOMMANDS.includes(subcommand)) {
|
|
93
|
+
return `"${subcommand}" is not permitted here; use one of ${GIT_NETWORK_SUBCOMMANDS.join(', ')}`;
|
|
94
|
+
}
|
|
95
|
+
for (const token of tokens.slice(1)) {
|
|
96
|
+
if (BLOCKED_FLAGS.has(token))
|
|
97
|
+
return `"${token}" is not permitted by this tool`;
|
|
98
|
+
const blocked = BLOCKED_PREFIXES.find(prefix => token.startsWith(prefix));
|
|
99
|
+
if (blocked !== undefined)
|
|
100
|
+
return `"${blocked}…" is not permitted by this tool`;
|
|
101
|
+
if (subcommand === 'push') {
|
|
102
|
+
if (PUSH_BLOCKED_FLAGS.has(token))
|
|
103
|
+
return `"${token}" is not permitted: this tool never forces or deletes on the remote`;
|
|
104
|
+
if (token.startsWith('+') || token.startsWith(':')) {
|
|
105
|
+
return 'force/delete refspecs ("+" or ":") are not permitted: this tool never forces or deletes on the remote';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
/** Validate one raw request or throw the reason as a plain Error. */
|
|
112
|
+
function requestOf(args) {
|
|
113
|
+
const issue = gitNetworkRequestIssue(args.repoPath, args.args);
|
|
114
|
+
if (issue !== undefined)
|
|
115
|
+
throw new Error(issue);
|
|
116
|
+
return { repoPath: args.repoPath, args: [...args.args] };
|
|
117
|
+
}
|
|
118
|
+
/** Cut one stream at the cap; `truncated` reports whether anything was lost. */
|
|
119
|
+
function cap(text) {
|
|
120
|
+
return text.length <= MAX_OUTPUT_CHARS
|
|
121
|
+
? { text, truncated: false }
|
|
122
|
+
: { text: text.slice(0, MAX_OUTPUT_CHARS), truncated: true };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove stored secrets — raw and in the Basic header's base64 form — from
|
|
126
|
+
* git's own diagnostics. git does not echo the Authorization header, but the
|
|
127
|
+
* redaction also covers a remote URL that embeds the token, and costs one
|
|
128
|
+
* pass per stored host.
|
|
129
|
+
* @param text - one captured stream.
|
|
130
|
+
* @param entries - the credentials that were injected.
|
|
131
|
+
*/
|
|
132
|
+
export function redactSecrets(text, entries) {
|
|
133
|
+
let out = text;
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (entry.secret !== '')
|
|
136
|
+
out = out.split(entry.secret).join('****');
|
|
137
|
+
const basic = Buffer.from(`${basicUsername(entry)}:${entry.secret}`, 'utf8').toString('base64');
|
|
138
|
+
out = out.split(basic).join('****');
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
/** Compose the model-facing text of one result. */
|
|
143
|
+
export function renderGitNetwork(value) {
|
|
144
|
+
const hosts = value.authenticatedHosts.length === 0 ? 'none' : value.authenticatedHosts.join(', ');
|
|
145
|
+
const lines = [
|
|
146
|
+
`git ${value.subcommand} (exit ${String(value.exitCode)}) in ${value.repoPath}`,
|
|
147
|
+
`credentials injected for: ${hosts}`,
|
|
148
|
+
];
|
|
149
|
+
if (value.credentialsError !== undefined)
|
|
150
|
+
lines.push(`credentials unavailable: ${value.credentialsError}`);
|
|
151
|
+
if (value.stdout.trim() !== '')
|
|
152
|
+
lines.push('--- stdout ---', value.stdout.trimEnd());
|
|
153
|
+
if (value.stderr.trim() !== '')
|
|
154
|
+
lines.push('--- stderr ---', value.stderr.trimEnd());
|
|
155
|
+
if (value.truncated)
|
|
156
|
+
lines.push('(output truncated at the length cap)');
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build the `git_network` tool definition.
|
|
161
|
+
* @param deps - host seams (home/env, an optional store and an optional exec).
|
|
162
|
+
* @returns a definition ready for `ctx.tools.register`.
|
|
163
|
+
*/
|
|
164
|
+
export function gitNetworkTool(deps) {
|
|
165
|
+
const storeOf = () => deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
166
|
+
const exec = deps.exec ?? childProcessExec;
|
|
167
|
+
return {
|
|
168
|
+
name: GIT_NETWORK_TOOL_NAME,
|
|
169
|
+
description: 'Run ONE git network command (fetch, pull, push or ls-remote) inside a repository with the '
|
|
170
|
+
+ 'plugin\'s stored Git credentials injected, so the command authenticates as the account configured in the '
|
|
171
|
+
+ 'Git credential panel instead of whatever the machine\'s git credential helper happens to hold. Prefer it for '
|
|
172
|
+
+ 'every push/fetch/pull: the credential is injected explicitly and redacted from the output, while a plain '
|
|
173
|
+
+ 'shell `git push` only reaches these credentials through the environment bridge the plugin host installs '
|
|
174
|
+
+ 'for the shells it spawns. That bridge is absent in an older host or in a terminal started outside it; there '
|
|
175
|
+
+ 'git clears the machine helper chain too, so an unconfigured host fails fast with a "terminal prompts '
|
|
176
|
+
+ 'disabled" error instead of opening a credential dialog nobody can answer. Pass the git '
|
|
177
|
+
+ 'arguments subcommand-first, for example ["push"], ["fetch", "--all", "--prune"] or '
|
|
178
|
+
+ '["push", "origin", "main"]. Force/delete refspecs, --force/--delete/--mirror/--prune and '
|
|
179
|
+
+ '--upload-pack/--receive-pack style options are rejected by design. The secret itself never appears in the '
|
|
180
|
+
+ 'result: only exit status, redacted stdout/stderr and the hosts that were authenticated.',
|
|
181
|
+
parameters: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: {
|
|
184
|
+
repoPath: {
|
|
185
|
+
type: 'string',
|
|
186
|
+
description: 'Absolute path to any directory inside the target repository; the command runs with this as cwd.',
|
|
187
|
+
},
|
|
188
|
+
args: {
|
|
189
|
+
type: 'array',
|
|
190
|
+
items: { type: 'string' },
|
|
191
|
+
description: 'Git arguments with the subcommand first. Permitted subcommands: fetch, pull, push, ls-remote.',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
required: ['repoPath', 'args'],
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
},
|
|
197
|
+
output: {
|
|
198
|
+
schema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
repoPath: { type: 'string' },
|
|
202
|
+
subcommand: { type: 'string' },
|
|
203
|
+
exitCode: { type: 'integer' },
|
|
204
|
+
stdout: { type: 'string' },
|
|
205
|
+
stderr: { type: 'string' },
|
|
206
|
+
authenticatedHosts: { type: 'array', items: { type: 'string' } },
|
|
207
|
+
truncated: { type: 'boolean' },
|
|
208
|
+
credentialsError: { type: 'string' },
|
|
209
|
+
},
|
|
210
|
+
required: ['repoPath', 'subcommand', 'exitCode', 'stdout', 'stderr', 'authenticatedHosts', 'truncated'],
|
|
211
|
+
},
|
|
212
|
+
render: (_args, value) => [
|
|
213
|
+
{ type: 'text', text: renderGitNetwork(value) },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
async execute(raw) {
|
|
217
|
+
const request = requestOf(raw ?? {});
|
|
218
|
+
let entries = [];
|
|
219
|
+
let credentialsError;
|
|
220
|
+
try {
|
|
221
|
+
entries = await storeOf().load();
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
// A damaged credential file must not make the tool unusable: run the
|
|
225
|
+
// command bare and report why, exactly like the routes do.
|
|
226
|
+
credentialsError = error instanceof Error ? error.message : String(error);
|
|
227
|
+
}
|
|
228
|
+
const hosts = [...new Set(entries.map(entry => entry.host))].sort();
|
|
229
|
+
const result = await exec('git', [...authConfigArgs(entries), ...request.args], {
|
|
230
|
+
cwd: request.repoPath,
|
|
231
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
232
|
+
});
|
|
233
|
+
const stdout = cap(redactSecrets(result.stdout, entries));
|
|
234
|
+
const stderrSource = result.stderr.trim() === '' && result.errorMessage !== undefined
|
|
235
|
+
? result.errorMessage
|
|
236
|
+
: result.stderr;
|
|
237
|
+
const stderr = cap(redactSecrets(stderrSource, entries));
|
|
238
|
+
return {
|
|
239
|
+
repoPath: request.repoPath,
|
|
240
|
+
subcommand: request.args[0] ?? '',
|
|
241
|
+
exitCode: result.code,
|
|
242
|
+
stdout: stdout.text,
|
|
243
|
+
stderr: stderr.text,
|
|
244
|
+
authenticatedHosts: hosts,
|
|
245
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
246
|
+
...(credentialsError === undefined ? {} : { credentialsError }),
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
package/lib/routes.d.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface RouteDeps {
|
|
|
44
44
|
/** Global per-host credential store. Absent = the fs-backed store at the
|
|
45
45
|
* resolved dsh home; tests substitute an in-memory one. */
|
|
46
46
|
credentialsStore?: CredentialStore;
|
|
47
|
+
/** Re-derive the shell credential bridge after the store changed (absent =
|
|
48
|
+
* a host that does not provide the bridge; tests omit it). */
|
|
49
|
+
refreshShellBridge?: () => Promise<void>;
|
|
47
50
|
}
|
|
48
51
|
/** Real fs-backed one-level directory listing (names only). Plain FILES are
|
|
49
52
|
* filtered out — the storage root can carry strays (a legacy settings.json
|
package/lib/routes.js
CHANGED
|
@@ -73,6 +73,23 @@ async function authArgsFor(deps) {
|
|
|
73
73
|
return [];
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Re-derive the shell credential bridge after a credential changed, so a
|
|
78
|
+
* message sent in the same session can `git push` with the token the user just
|
|
79
|
+
* saved. A bridge failure must never fail the save itself: the store write has
|
|
80
|
+
* already landed, and the worst case is a stale environment until the next
|
|
81
|
+
* save (an added host is not reachable, a deleted one may still be).
|
|
82
|
+
*/
|
|
83
|
+
async function refreshBridge(deps) {
|
|
84
|
+
if (deps.refreshShellBridge === undefined)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
await deps.refreshShellBridge();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Deliberately swallowed; see the contract above.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
76
93
|
/** Upper bound on distinct paths one /group request may probe. */
|
|
77
94
|
const GROUP_PATHS_LIMIT = 256;
|
|
78
95
|
/** Probes per in-flight batch — polite on Windows, where each git call is a
|
|
@@ -1362,6 +1379,7 @@ export async function handleCredentialsSave(deps, body) {
|
|
|
1362
1379
|
updatedAt: Date.now(),
|
|
1363
1380
|
});
|
|
1364
1381
|
await store.save(next);
|
|
1382
|
+
await refreshBridge(deps);
|
|
1365
1383
|
const result = { credentials: sortCredentials(next).map(credentialView) };
|
|
1366
1384
|
return { status: 200, body: result };
|
|
1367
1385
|
}
|
|
@@ -1389,6 +1407,7 @@ export async function handleCredentialsDelete(deps, body) {
|
|
|
1389
1407
|
if (next.length === entries.length)
|
|
1390
1408
|
return fail(400, `no credential is stored for "${host}"`);
|
|
1391
1409
|
await store.save(next);
|
|
1410
|
+
await refreshBridge(deps);
|
|
1392
1411
|
const result = { credentials: sortCredentials(next).map(credentialView) };
|
|
1393
1412
|
return { status: 200, body: result };
|
|
1394
1413
|
}
|
|
@@ -1495,6 +1514,7 @@ export async function handleCredentialsOAuthPoll(deps, body) {
|
|
|
1495
1514
|
updatedAt: Date.now(),
|
|
1496
1515
|
});
|
|
1497
1516
|
await store.save(next);
|
|
1517
|
+
await refreshBridge(deps);
|
|
1498
1518
|
const done = { status: 'done', credentials: sortCredentials(next).map(credentialView) };
|
|
1499
1519
|
return { status: 200, body: done };
|
|
1500
1520
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell credential bridge (host half). The credential panel stores one token
|
|
3
|
+
* per remote host, but 0029 could only inject it into the panel's own routes
|
|
4
|
+
* and the `git_network` tool; a plain `git push` run in a shell authenticated
|
|
5
|
+
* as whatever the machine's credential helper held — often a different
|
|
6
|
+
* account — or hung on an interactive prompt nobody could answer.
|
|
7
|
+
*
|
|
8
|
+
* This module closes that gap without touching the user's git configuration
|
|
9
|
+
* FILES (0021's constraint): it exports `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/
|
|
10
|
+
* `GIT_CONFIG_VALUE_n` entries that install the bundled credential helper
|
|
11
|
+
* (`helpers/git-credential-bridge.js`) as a URL-scoped helper for every stored
|
|
12
|
+
* host. git reads that environment as configuration, every child process the
|
|
13
|
+
* host spawns inherits it, and a non-empty value list is always preceded by the
|
|
14
|
+
* same key with an empty value — git resets the accumulated helper list
|
|
15
|
+
* there, so the machine chain (`helper-selector` → GCM) can neither answer
|
|
16
|
+
* with a different account nor open a blocking dialog for those hosts.
|
|
17
|
+
* The list is reset globally as well: a host with no stored credential then
|
|
18
|
+
* fails at once ("terminal prompts disabled") instead of reaching the machine
|
|
19
|
+
* chain, whose interactive GUI dialog nobody in an agent shell can answer.
|
|
20
|
+
*
|
|
21
|
+
* `GIT_TERMINAL_PROMPT=0` and `GCM_INTERACTIVE=never` ride along so a host
|
|
22
|
+
* with no usable credential fails fast instead of parking a shell on a prompt
|
|
23
|
+
* (the failure mode that started this: a 3m38s silent hang).
|
|
24
|
+
*
|
|
25
|
+
* The assignments are re-derived whenever the credential store changes
|
|
26
|
+
* (`src/routes.ts` calls the bridge after every save / delete / sign-in).
|
|
27
|
+
*/
|
|
28
|
+
import { type CredentialStore } from './credentials.js';
|
|
29
|
+
/** Env var carrying the credential file path to the helper script. */
|
|
30
|
+
export declare const BRIDGE_HELPER_ENV = "DSH_GIT_BRIDGE_HELPER";
|
|
31
|
+
/** Env var carrying a debug log path to the helper script (unset by default). */
|
|
32
|
+
export declare const BRIDGE_DEBUG_ENV = "DSH_GIT_BRIDGE_DEBUG";
|
|
33
|
+
/** Host-side seams the bridge reads through (tests substitute all of them). */
|
|
34
|
+
export interface ShellBridgeDeps {
|
|
35
|
+
/** User home directory seam. */
|
|
36
|
+
readonly home: () => string;
|
|
37
|
+
/** `$DSH_HOME` seam (blank/undefined = unset). */
|
|
38
|
+
readonly envHome: () => string | undefined;
|
|
39
|
+
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
40
|
+
readonly store?: CredentialStore;
|
|
41
|
+
/** The environment object to mutate; `process.env` when absent. */
|
|
42
|
+
readonly env?: Record<string, string | undefined>;
|
|
43
|
+
/** Absolute helper script path; the compiled sibling under `helpers/` when
|
|
44
|
+
* absent. */
|
|
45
|
+
readonly helperPath?: string;
|
|
46
|
+
/** Node executable that runs the helper; `process.execPath` when absent. */
|
|
47
|
+
readonly nodePath?: string;
|
|
48
|
+
}
|
|
49
|
+
/** The bridge's one operation: re-derive the environment from the store. */
|
|
50
|
+
export interface ShellBridge {
|
|
51
|
+
/** Read the credential store and rewrite the injected environment.
|
|
52
|
+
* A store that cannot be read drops every URL-scoped helper (no host can be
|
|
53
|
+
* bridged), but keeps the global reset, so shell git still fails fast
|
|
54
|
+
* instead of opening an interactive credential dialog. */
|
|
55
|
+
readonly apply: () => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
/** One ordered environment assignment. */
|
|
58
|
+
export type BridgeAssignment = readonly [key: string, value: string];
|
|
59
|
+
/**
|
|
60
|
+
* Quote one executable or path for git's `!` shell command. Backslashes are
|
|
61
|
+
* normalized to forward slashes first: git runs `!` helpers through `sh`,
|
|
62
|
+
* where `C:\…` would be read as escape sequences.
|
|
63
|
+
* @param value - an absolute path.
|
|
64
|
+
*/
|
|
65
|
+
export declare function shellQuote(value: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* The helper command git runs for one configured host: a `!`-prefixed shell
|
|
68
|
+
* command, because git does not word-split a helper value by itself (a bare
|
|
69
|
+
* `node "C:/…/bridge.js"` is looked up as the program `git-credential-node`).
|
|
70
|
+
* @param nodePath - the node executable.
|
|
71
|
+
* @param helperPath - the compiled helper script.
|
|
72
|
+
*/
|
|
73
|
+
export declare function bridgeCommand(nodePath: string, helperPath: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* The ordered environment assignments for the given hosts: per host an empty
|
|
76
|
+
* helper value (git resets the accumulated list for that URL scope) and then
|
|
77
|
+
* the bridge command, plus the credential-file variable the helper reads and
|
|
78
|
+
* the two fail-fast switches.
|
|
79
|
+
* @param hosts - normalized hosts with a stored credential.
|
|
80
|
+
* @param options - process paths and the credential file.
|
|
81
|
+
*/
|
|
82
|
+
export declare function shellBridgeEnv(hosts: readonly string[], options: {
|
|
83
|
+
readonly nodePath: string;
|
|
84
|
+
readonly helperPath: string;
|
|
85
|
+
readonly credentialFile: string;
|
|
86
|
+
}): readonly BridgeAssignment[];
|
|
87
|
+
/**
|
|
88
|
+
* Build the bridge over one host process. Every {@link ShellBridge.apply}
|
|
89
|
+
* first removes the assignments it made last time, so a deleted host loses
|
|
90
|
+
* its helper scope just like a freshly added one gains it.
|
|
91
|
+
* @param deps - host seams.
|
|
92
|
+
*/
|
|
93
|
+
export declare function createShellBridge(deps: ShellBridgeDeps): ShellBridge;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell credential bridge (host half). The credential panel stores one token
|
|
3
|
+
* per remote host, but 0029 could only inject it into the panel's own routes
|
|
4
|
+
* and the `git_network` tool; a plain `git push` run in a shell authenticated
|
|
5
|
+
* as whatever the machine's credential helper held — often a different
|
|
6
|
+
* account — or hung on an interactive prompt nobody could answer.
|
|
7
|
+
*
|
|
8
|
+
* This module closes that gap without touching the user's git configuration
|
|
9
|
+
* FILES (0021's constraint): it exports `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/
|
|
10
|
+
* `GIT_CONFIG_VALUE_n` entries that install the bundled credential helper
|
|
11
|
+
* (`helpers/git-credential-bridge.js`) as a URL-scoped helper for every stored
|
|
12
|
+
* host. git reads that environment as configuration, every child process the
|
|
13
|
+
* host spawns inherits it, and a non-empty value list is always preceded by the
|
|
14
|
+
* same key with an empty value — git resets the accumulated helper list
|
|
15
|
+
* there, so the machine chain (`helper-selector` → GCM) can neither answer
|
|
16
|
+
* with a different account nor open a blocking dialog for those hosts.
|
|
17
|
+
* The list is reset globally as well: a host with no stored credential then
|
|
18
|
+
* fails at once ("terminal prompts disabled") instead of reaching the machine
|
|
19
|
+
* chain, whose interactive GUI dialog nobody in an agent shell can answer.
|
|
20
|
+
*
|
|
21
|
+
* `GIT_TERMINAL_PROMPT=0` and `GCM_INTERACTIVE=never` ride along so a host
|
|
22
|
+
* with no usable credential fails fast instead of parking a shell on a prompt
|
|
23
|
+
* (the failure mode that started this: a 3m38s silent hang).
|
|
24
|
+
*
|
|
25
|
+
* The assignments are re-derived whenever the credential store changes
|
|
26
|
+
* (`src/routes.ts` calls the bridge after every save / delete / sign-in).
|
|
27
|
+
*/
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { credentialFilePath, createCredentialStore, } from './credentials.js';
|
|
30
|
+
/** Env var carrying the credential file path to the helper script. */
|
|
31
|
+
export const BRIDGE_HELPER_ENV = 'DSH_GIT_BRIDGE_HELPER';
|
|
32
|
+
/** Env var carrying a debug log path to the helper script (unset by default). */
|
|
33
|
+
export const BRIDGE_DEBUG_ENV = 'DSH_GIT_BRIDGE_DEBUG';
|
|
34
|
+
/**
|
|
35
|
+
* Quote one executable or path for git's `!` shell command. Backslashes are
|
|
36
|
+
* normalized to forward slashes first: git runs `!` helpers through `sh`,
|
|
37
|
+
* where `C:\…` would be read as escape sequences.
|
|
38
|
+
* @param value - an absolute path.
|
|
39
|
+
*/
|
|
40
|
+
export function shellQuote(value) {
|
|
41
|
+
return `"${value.replace(/\\/g, '/')}"`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The helper command git runs for one configured host: a `!`-prefixed shell
|
|
45
|
+
* command, because git does not word-split a helper value by itself (a bare
|
|
46
|
+
* `node "C:/…/bridge.js"` is looked up as the program `git-credential-node`).
|
|
47
|
+
* @param nodePath - the node executable.
|
|
48
|
+
* @param helperPath - the compiled helper script.
|
|
49
|
+
*/
|
|
50
|
+
export function bridgeCommand(nodePath, helperPath) {
|
|
51
|
+
return `!${shellQuote(nodePath)} ${shellQuote(helperPath)}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The ordered environment assignments for the given hosts: per host an empty
|
|
55
|
+
* helper value (git resets the accumulated list for that URL scope) and then
|
|
56
|
+
* the bridge command, plus the credential-file variable the helper reads and
|
|
57
|
+
* the two fail-fast switches.
|
|
58
|
+
* @param hosts - normalized hosts with a stored credential.
|
|
59
|
+
* @param options - process paths and the credential file.
|
|
60
|
+
*/
|
|
61
|
+
export function shellBridgeEnv(hosts, options) {
|
|
62
|
+
const unique = [...new Set(hosts)];
|
|
63
|
+
const assignments = [];
|
|
64
|
+
// Reset the WHOLE helper list first, even when no host is stored. Without it,
|
|
65
|
+
// any host without a stored credential still reaches the machine chain
|
|
66
|
+
// (`helper-selector` → GCM), which raises a GUI prompt no agent shell can
|
|
67
|
+
// answer — the dialog that kept popping up. With it, such a host fails at
|
|
68
|
+
// once with "terminal prompts disabled", and the URL-scoped pairs below are
|
|
69
|
+
// the only way credentials get back into a shell.
|
|
70
|
+
assignments.push(['GIT_CONFIG_COUNT', String(unique.length * 2 + 1)]);
|
|
71
|
+
assignments.push(['GIT_CONFIG_KEY_0', 'credential.helper'], ['GIT_CONFIG_VALUE_0', '']);
|
|
72
|
+
unique.forEach((host, index) => {
|
|
73
|
+
const key = `credential.https://${host}/.helper`;
|
|
74
|
+
const empty = String(index * 2 + 1);
|
|
75
|
+
const command = String(index * 2 + 2);
|
|
76
|
+
assignments.push([`GIT_CONFIG_KEY_${empty}`, key], [`GIT_CONFIG_VALUE_${empty}`, '']);
|
|
77
|
+
assignments.push([`GIT_CONFIG_KEY_${command}`, key], [`GIT_CONFIG_VALUE_${command}`, bridgeCommand(options.nodePath, options.helperPath)]);
|
|
78
|
+
});
|
|
79
|
+
assignments.push([BRIDGE_HELPER_ENV, options.credentialFile]);
|
|
80
|
+
// Fail fast instead of parking a shell on a prompt or a GUI dialog.
|
|
81
|
+
assignments.push(['GIT_TERMINAL_PROMPT', '0'], ['GCM_INTERACTIVE', 'never']);
|
|
82
|
+
return assignments;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build the bridge over one host process. Every {@link ShellBridge.apply}
|
|
86
|
+
* first removes the assignments it made last time, so a deleted host loses
|
|
87
|
+
* its helper scope just like a freshly added one gains it.
|
|
88
|
+
* @param deps - host seams.
|
|
89
|
+
*/
|
|
90
|
+
export function createShellBridge(deps) {
|
|
91
|
+
const env = deps.env ?? process.env;
|
|
92
|
+
let applied = [];
|
|
93
|
+
return {
|
|
94
|
+
async apply() {
|
|
95
|
+
let hosts = [];
|
|
96
|
+
try {
|
|
97
|
+
hosts = (await (deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()))).load()).map(entry => entry.host);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// An unreadable store means no host can be bridged. The global helper
|
|
101
|
+
// reset still goes out, so shell git fails fast rather than popping a
|
|
102
|
+
// credential dialog nobody can answer.
|
|
103
|
+
hosts = [];
|
|
104
|
+
}
|
|
105
|
+
const helperPath = deps.helperPath
|
|
106
|
+
?? fileURLToPath(new URL('./helpers/git-credential-bridge.js', import.meta.url));
|
|
107
|
+
const assignments = shellBridgeEnv(hosts, {
|
|
108
|
+
nodePath: deps.nodePath ?? process.execPath,
|
|
109
|
+
helperPath,
|
|
110
|
+
credentialFile: credentialFilePath(deps.home(), deps.envHome()),
|
|
111
|
+
});
|
|
112
|
+
for (const key of applied)
|
|
113
|
+
delete env[key];
|
|
114
|
+
applied = [];
|
|
115
|
+
for (const [key, value] of assignments) {
|
|
116
|
+
env[key] = value;
|
|
117
|
+
applied.push(key);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/package.json
CHANGED