dsh-git-branch-manage 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/lib/credential-service.d.ts +74 -0
- package/lib/credential-service.js +56 -0
- package/lib/credentials.d.ts +7 -3
- package/lib/credentials.js +7 -3
- package/lib/helpers/git-credential-bridge.d.ts +37 -0
- package/lib/helpers/git-credential-bridge.js +119 -0
- package/lib/index.js +25 -8
- package/lib/network-tool.d.ts +12 -8
- package/lib/network-tool.js +15 -11
- package/lib/routes.d.ts +7 -0
- package/lib/routes.js +27 -3
- package/lib/shell-bridge.d.ts +143 -0
- package/lib/shell-bridge.js +218 -0
- package/lib/tool.d.ts +4 -0
- package/lib/tool.js +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 变更日志
|
|
2
|
+
|
|
3
|
+
本文件记录 `dsh-git-branch-manage` 的重要变更。版本号遵循语义化版本。
|
|
4
|
+
|
|
5
|
+
## [1.1.0] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **宿主里的 `git` 命令全部 `fatal`**(`fatal: unable to parse command-line config`):0032 的 `GIT_CONFIG_KEY/VALUE_*` 环境注入被 DeepSeek Harness 的子进程环境清洗(按 `/KEY|PASSWORD|SECRET|TOKEN/i` 过滤变量名、并剥掉 `DSH_*` 前缀)半剥离——`KEY_n` 被删、`COUNT/VALUE_n` 幸存。改用 `GIT_CONFIG_GLOBAL` 指向一份会话临时 gitconfig 注入凭据(决策 0034)。
|
|
10
|
+
- **shell 里的 `git` 走本机 gh 账号而非面板令牌**:会话配置用 URL 作用域的空 `helper` 重置,清掉 `~/.gitconfig` 里 `[credential "https://github.com"]` 的 gh helper,让已配置域名命中面板令牌。
|
|
11
|
+
- **未配置域名弹 GCM 凭据对话框 / 挂死**:全局空 `helper` 清空机器 helper 链,未配置域名 `exit 128` 快速失败,不再弹窗。
|
|
12
|
+
- **含空格的 node/helper 路径调用失败**(`sh: C:/Program: No such file or directory`):`gitconfigEscape` / `shellQuote` 正确转义引号与反斜杠。
|
|
13
|
+
|
|
14
|
+
### 变更
|
|
15
|
+
|
|
16
|
+
- 新增 `createCredentialService`,把凭据入口收敛为单一服务(`store` / `authConfigArgs` / `refreshShellEnv` / `dispose`);routes、`git_network`、密钥工具统一经它取 store 与注入通道(决策 0034)。
|
|
17
|
+
- 桥 helper 环境变量改 `DGB_*` 命名(`DGB_BRIDGE_HELPER` 等),避开 DSH 环境清洗。
|
|
18
|
+
- 新增真实 `git` 端到端回归测试(`shell-bridge-e2e`):验证已配置域名命中桥令牌、空格路径正确调用、未配置域名快速失败。
|
|
19
|
+
|
|
20
|
+
## [1.0.10] - 2026-10-30
|
|
21
|
+
|
|
22
|
+
- shell 里的 `git push` 也走插件密钥(环境变量桥,决策 0032)。注:该注入通道在 DeepSeek Harness 宿主中失效,已由 1.1.0 修正(决策 0034)。
|
|
23
|
+
|
|
24
|
+
更早版本见 [GitHub Releases](https://github.com/GaoHu1997/dsh-git-branch/releases)。
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one entry point every credential channel goes through (decision 0034).
|
|
3
|
+
*
|
|
4
|
+
* Before this module the store, the `-c http.<url>.extraHeader` injection
|
|
5
|
+
* (`authConfigArgs`, 0021/0029) and the shell environment bridge
|
|
6
|
+
* (`shell-bridge.ts`, 0032/0034) were assembled in three places — the routes,
|
|
7
|
+
* the two model-facing tools and the plugin entry — each resolving the store
|
|
8
|
+
* path itself. Every future plugin that needs git credentials would have
|
|
9
|
+
* repeated that wiring and drifted from it.
|
|
10
|
+
*
|
|
11
|
+
* `createCredentialService` owns the store instance and both injection
|
|
12
|
+
* channels, so callers share one contract:
|
|
13
|
+
*
|
|
14
|
+
* - {@link CredentialService.authConfigArgs} — the `-c extraHeader` pairs for
|
|
15
|
+
* git commands the plugin itself runs (panel routes, `git_network`). The
|
|
16
|
+
* token rides the argv of that one command and never an environment.
|
|
17
|
+
* - {@link CredentialService.refreshShellEnv} — re-derive the session
|
|
18
|
+
* gitconfig + `GIT_CONFIG_GLOBAL` environment so a plain shell `git push`
|
|
19
|
+
* authenticates as configured. Call it after every store change.
|
|
20
|
+
* - {@link CredentialService.dispose} — undo the environment injection and
|
|
21
|
+
* delete the session config file (plugin teardown).
|
|
22
|
+
*
|
|
23
|
+
* A store that cannot be read degrades to "no credentials" on every channel
|
|
24
|
+
* (an unauthenticated command runs as it always did; the credentials route is
|
|
25
|
+
* where the file problem surfaces) — a broken file must never break git.
|
|
26
|
+
*/
|
|
27
|
+
import { type CredentialStore } from './credentials.js';
|
|
28
|
+
/** Host-side seams the service reads through (tests substitute all of them). */
|
|
29
|
+
export interface CredentialServiceDeps {
|
|
30
|
+
/** User home directory seam. */
|
|
31
|
+
readonly home: () => string;
|
|
32
|
+
/** `$DSH_HOME` seam (blank/undefined = unset). */
|
|
33
|
+
readonly envHome: () => string | undefined;
|
|
34
|
+
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
35
|
+
readonly store?: CredentialStore;
|
|
36
|
+
/** The environment object to mutate; `process.env` when absent. */
|
|
37
|
+
readonly env?: Record<string, string | undefined>;
|
|
38
|
+
/** Absolute helper script path; the compiled sibling under `helpers/` when absent. */
|
|
39
|
+
readonly helperPath?: string;
|
|
40
|
+
/** Node executable that runs the helper; `process.execPath` when absent. */
|
|
41
|
+
readonly nodePath?: string;
|
|
42
|
+
/** Directory for the session config; the OS temp dir when absent. */
|
|
43
|
+
readonly configDir?: () => string;
|
|
44
|
+
/** Exact session config path (tests); derived from the pid when absent. */
|
|
45
|
+
readonly configPath?: string;
|
|
46
|
+
/** User global config the session file includes; `<home>/.gitconfig` when absent. */
|
|
47
|
+
readonly userGitconfig?: string;
|
|
48
|
+
}
|
|
49
|
+
/** The single credential entry point. */
|
|
50
|
+
export interface CredentialService {
|
|
51
|
+
/** The shared global store (one row per remote host). */
|
|
52
|
+
readonly store: CredentialStore;
|
|
53
|
+
/**
|
|
54
|
+
* `-c http.<url>.extraHeader` pairs for every stored credential, ready to
|
|
55
|
+
* prepend to a git network command the plugin runs itself. A broken store
|
|
56
|
+
* degrades to "no credentials" rather than throwing.
|
|
57
|
+
*/
|
|
58
|
+
readonly authConfigArgs: () => Promise<readonly string[]>;
|
|
59
|
+
/**
|
|
60
|
+
* Re-derive the shell credential environment after a credential changed, so
|
|
61
|
+
* a message sent in the same session can `git push` with the token the user
|
|
62
|
+
* just saved. Never throws: the worst case is a stale environment until the
|
|
63
|
+
* next refresh.
|
|
64
|
+
*/
|
|
65
|
+
readonly refreshShellEnv: () => Promise<void>;
|
|
66
|
+
/** Remove the environment injection and delete the session config file. */
|
|
67
|
+
readonly dispose: () => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the credential service for one host process: one store instance, one
|
|
71
|
+
* session-gitconfig bridge, shared by the routes and every tool.
|
|
72
|
+
* @param deps - host seams (see {@link CredentialServiceDeps}).
|
|
73
|
+
*/
|
|
74
|
+
export declare function createCredentialService(deps: CredentialServiceDeps): CredentialService;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one entry point every credential channel goes through (decision 0034).
|
|
3
|
+
*
|
|
4
|
+
* Before this module the store, the `-c http.<url>.extraHeader` injection
|
|
5
|
+
* (`authConfigArgs`, 0021/0029) and the shell environment bridge
|
|
6
|
+
* (`shell-bridge.ts`, 0032/0034) were assembled in three places — the routes,
|
|
7
|
+
* the two model-facing tools and the plugin entry — each resolving the store
|
|
8
|
+
* path itself. Every future plugin that needs git credentials would have
|
|
9
|
+
* repeated that wiring and drifted from it.
|
|
10
|
+
*
|
|
11
|
+
* `createCredentialService` owns the store instance and both injection
|
|
12
|
+
* channels, so callers share one contract:
|
|
13
|
+
*
|
|
14
|
+
* - {@link CredentialService.authConfigArgs} — the `-c extraHeader` pairs for
|
|
15
|
+
* git commands the plugin itself runs (panel routes, `git_network`). The
|
|
16
|
+
* token rides the argv of that one command and never an environment.
|
|
17
|
+
* - {@link CredentialService.refreshShellEnv} — re-derive the session
|
|
18
|
+
* gitconfig + `GIT_CONFIG_GLOBAL` environment so a plain shell `git push`
|
|
19
|
+
* authenticates as configured. Call it after every store change.
|
|
20
|
+
* - {@link CredentialService.dispose} — undo the environment injection and
|
|
21
|
+
* delete the session config file (plugin teardown).
|
|
22
|
+
*
|
|
23
|
+
* A store that cannot be read degrades to "no credentials" on every channel
|
|
24
|
+
* (an unauthenticated command runs as it always did; the credentials route is
|
|
25
|
+
* where the file problem surfaces) — a broken file must never break git.
|
|
26
|
+
*/
|
|
27
|
+
import { authConfigArgs, createCredentialStore, credentialFilePath, } from './credentials.js';
|
|
28
|
+
import { createShellBridge } from './shell-bridge.js';
|
|
29
|
+
/**
|
|
30
|
+
* Build the credential service for one host process: one store instance, one
|
|
31
|
+
* session-gitconfig bridge, shared by the routes and every tool.
|
|
32
|
+
* @param deps - host seams (see {@link CredentialServiceDeps}).
|
|
33
|
+
*/
|
|
34
|
+
export function createCredentialService(deps) {
|
|
35
|
+
const store = deps.store
|
|
36
|
+
?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
37
|
+
const bridgeDeps = { ...deps, store };
|
|
38
|
+
const shellBridge = createShellBridge(bridgeDeps);
|
|
39
|
+
return {
|
|
40
|
+
store,
|
|
41
|
+
async authConfigArgs() {
|
|
42
|
+
try {
|
|
43
|
+
return authConfigArgs(await store.load());
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
refreshShellEnv() {
|
|
50
|
+
return shellBridge.apply();
|
|
51
|
+
},
|
|
52
|
+
dispose() {
|
|
53
|
+
return shellBridge.dispose();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
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,37 @@
|
|
|
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 writes a session gitconfig (see `src/shell-bridge.ts`) that
|
|
8
|
+
* installs this script as a URL-scoped credential helper for every stored
|
|
9
|
+
* host, so commands the model runs in the shell authenticate as the
|
|
10
|
+
* configured account too.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints (see docs/decisions/0029, 0032 and 0034):
|
|
13
|
+
* - The user's global git config and credential helpers are never written;
|
|
14
|
+
* the injection lives in one generated session config plus the process
|
|
15
|
+
* environment only.
|
|
16
|
+
* - The token never enters the conversation: this script reads the
|
|
17
|
+
* credential file directly on the host and answers git on stdout.
|
|
18
|
+
* - URL scope (`credential.https://<host>/`) confines the helper to hosts
|
|
19
|
+
* with a stored credential. The host half also clears the machine helper
|
|
20
|
+
* list, so a host this script cannot answer fails fast instead of opening
|
|
21
|
+
* an interactive credential dialog.
|
|
22
|
+
* - `get` is silent on a miss (git falls through to the next helper);
|
|
23
|
+
* `store`/`erase` are pass-through by decision — writing back would race
|
|
24
|
+
* the panel's atomic save and leak the file shape.
|
|
25
|
+
*
|
|
26
|
+
* Environment contract (set by src/shell-bridge.ts). The names carry no
|
|
27
|
+
* KEY/PASSWORD/SECRET/TOKEN substring and no `DSH_` prefix, because the DSH
|
|
28
|
+
* subprocess scrubber strips exactly those from inherited environments
|
|
29
|
+
* (decision 0034; a stripped path made this helper read the wrong file):
|
|
30
|
+
* - `DGB_BRIDGE_HELPER` — credential file path (required).
|
|
31
|
+
* - `DGB_BRIDGE_HOME` — user home directory (fallback resolution).
|
|
32
|
+
* - `DGB_BRIDGE_DSH_HOME` — `$DSH_HOME` override (optional).
|
|
33
|
+
* - `DGB_BRIDGE_DEBUG` — when non-blank, a debug log path (optional).
|
|
34
|
+
*
|
|
35
|
+
* @module dsh-git-branch/git-credential-bridge
|
|
36
|
+
*/
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
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 writes a session gitconfig (see `src/shell-bridge.ts`) that
|
|
8
|
+
* installs this script as a URL-scoped credential helper for every stored
|
|
9
|
+
* host, so commands the model runs in the shell authenticate as the
|
|
10
|
+
* configured account too.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints (see docs/decisions/0029, 0032 and 0034):
|
|
13
|
+
* - The user's global git config and credential helpers are never written;
|
|
14
|
+
* the injection lives in one generated session config plus the process
|
|
15
|
+
* environment only.
|
|
16
|
+
* - The token never enters the conversation: this script reads the
|
|
17
|
+
* credential file directly on the host and answers git on stdout.
|
|
18
|
+
* - URL scope (`credential.https://<host>/`) confines the helper to hosts
|
|
19
|
+
* with a stored credential. The host half also clears the machine helper
|
|
20
|
+
* list, so a host this script cannot answer fails fast instead of opening
|
|
21
|
+
* an interactive credential dialog.
|
|
22
|
+
* - `get` is silent on a miss (git falls through to the next helper);
|
|
23
|
+
* `store`/`erase` are pass-through by decision — writing back would race
|
|
24
|
+
* the panel's atomic save and leak the file shape.
|
|
25
|
+
*
|
|
26
|
+
* Environment contract (set by src/shell-bridge.ts). The names carry no
|
|
27
|
+
* KEY/PASSWORD/SECRET/TOKEN substring and no `DSH_` prefix, because the DSH
|
|
28
|
+
* subprocess scrubber strips exactly those from inherited environments
|
|
29
|
+
* (decision 0034; a stripped path made this helper read the wrong file):
|
|
30
|
+
* - `DGB_BRIDGE_HELPER` — credential file path (required).
|
|
31
|
+
* - `DGB_BRIDGE_HOME` — user home directory (fallback resolution).
|
|
32
|
+
* - `DGB_BRIDGE_DSH_HOME` — `$DSH_HOME` override (optional).
|
|
33
|
+
* - `DGB_BRIDGE_DEBUG` — when non-blank, a debug log path (optional).
|
|
34
|
+
*
|
|
35
|
+
* @module dsh-git-branch/git-credential-bridge
|
|
36
|
+
*/
|
|
37
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
38
|
+
import { homedir } from 'node:os';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
/** git helpers receive exactly one argument: the operation. */
|
|
41
|
+
const OPERATION = process.argv[2] ?? 'get';
|
|
42
|
+
/** The credential file this helper reads (`DGB_BRIDGE_HELPER` wins). */
|
|
43
|
+
const DEFAULT_CREDENTIAL_FILE = 'git-credentials.json';
|
|
44
|
+
/** The dsh home directory below the user profile when `$DSH_HOME` is unset. */
|
|
45
|
+
const PACKAGE_HOME = '.dsh';
|
|
46
|
+
/** The Basic-auth name a token without a username is sent under. */
|
|
47
|
+
const TOKEN_USERNAME = 'oauth2';
|
|
48
|
+
/**
|
|
49
|
+
* Append one debug line behind the user's back; a broken log path must never
|
|
50
|
+
* break the credential lookup.
|
|
51
|
+
*/
|
|
52
|
+
function debug(line) {
|
|
53
|
+
const path = process.env.DGB_BRIDGE_DEBUG;
|
|
54
|
+
if (path === undefined || path.trim() === '')
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
appendFileSync(path, `${line}\n`, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Deliberately swallowed: debugging is best-effort.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The credential file this helper reads (`DGB_BRIDGE_HELPER` wins). */
|
|
64
|
+
function credentialFile() {
|
|
65
|
+
const helper = process.env.DGB_BRIDGE_HELPER;
|
|
66
|
+
if (helper !== undefined && helper.trim() !== '')
|
|
67
|
+
return helper;
|
|
68
|
+
const dshHome = process.env.DGB_BRIDGE_DSH_HOME;
|
|
69
|
+
const home = dshHome !== undefined && dshHome.trim() !== ''
|
|
70
|
+
? dshHome
|
|
71
|
+
: process.env.DGB_BRIDGE_HOME ?? homedir();
|
|
72
|
+
return join(home, PACKAGE_HOME, DEFAULT_CREDENTIAL_FILE);
|
|
73
|
+
}
|
|
74
|
+
/** One `key=value` request from git on stdin. */
|
|
75
|
+
function readRequest() {
|
|
76
|
+
const text = readFileSync(0, 'utf8');
|
|
77
|
+
const request = new Map();
|
|
78
|
+
for (const line of text.split('\n')) {
|
|
79
|
+
const at = line.indexOf('=');
|
|
80
|
+
if (at > 0)
|
|
81
|
+
request.set(line.slice(0, at), line.slice(at + 1));
|
|
82
|
+
}
|
|
83
|
+
return request;
|
|
84
|
+
}
|
|
85
|
+
/** The stored credential for one host, or undefined when there is none. */
|
|
86
|
+
function readCredential(host) {
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(readFileSync(credentialFile(), 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
debug(`read failed: ${String(error)}`);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const credentials = parsed.credentials;
|
|
96
|
+
if (!Array.isArray(credentials))
|
|
97
|
+
return undefined;
|
|
98
|
+
const entry = credentials.find((item) => typeof item === 'object' && item !== null && item.host === host);
|
|
99
|
+
if (entry === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const secret = typeof entry.secret === 'string' ? entry.secret : '';
|
|
102
|
+
if (secret === '')
|
|
103
|
+
return undefined;
|
|
104
|
+
const username = typeof entry.username === 'string' && entry.username.trim() !== ''
|
|
105
|
+
? entry.username
|
|
106
|
+
: TOKEN_USERNAME;
|
|
107
|
+
return { username, secret };
|
|
108
|
+
}
|
|
109
|
+
if (OPERATION === 'get') {
|
|
110
|
+
const request = readRequest();
|
|
111
|
+
const host = request.get('host') ?? '';
|
|
112
|
+
debug(`get host=${host} protocol=${request.get('protocol') ?? ''}`);
|
|
113
|
+
const credential = readCredential(host);
|
|
114
|
+
if (credential !== undefined) {
|
|
115
|
+
process.stdout.write(`username=${credential.username}\npassword=${credential.secret}\n`);
|
|
116
|
+
}
|
|
117
|
+
// A miss stays silent: git tries the next helper in the chain.
|
|
118
|
+
}
|
|
119
|
+
// `store` / `erase` are deliberate no-ops (see the header comment).
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { handleCredentials, handleCredentialsDelete, handleCredentialsOAuthPoll,
|
|
|
13
13
|
import { validateRootDir } from './settings.js';
|
|
14
14
|
import { gitNetworkTool } from './network-tool.js';
|
|
15
15
|
import { gitCredentialsTool } from './tool.js';
|
|
16
|
+
import { createCredentialService } from './credential-service.js';
|
|
16
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';
|
|
17
18
|
export const name = 'dsh-git-branch';
|
|
18
19
|
export const inject = [];
|
|
@@ -141,6 +142,19 @@ export function sectionOf(config) {
|
|
|
141
142
|
export function apply(ctx, config = {}) {
|
|
142
143
|
validateConfig(config);
|
|
143
144
|
const currentConfig = () => ctx.fiber?.config ?? config;
|
|
145
|
+
// Host half of the credential channels: one service owns the store, the
|
|
146
|
+
// `-c extraHeader` injection for plugin-run commands and the shell bridge
|
|
147
|
+
// (decision 0034). The bridge lets a plain `git push` in a spawned shell
|
|
148
|
+
// authenticate with the panel's stored token instead of the machine helper's
|
|
149
|
+
// account. It also clears the machine helper chain, so a host without a
|
|
150
|
+
// stored token fails fast rather than opening a credential dialog no agent
|
|
151
|
+
// shell can answer. Applied at activate and re-derived after every
|
|
152
|
+
// credential save / delete / sign-in.
|
|
153
|
+
const credentials = createCredentialService({ home: () => homedir(), envHome: () => process.env.DSH_HOME });
|
|
154
|
+
void credentials.refreshShellEnv();
|
|
155
|
+
ctx.effect(() => () => {
|
|
156
|
+
void credentials.dispose();
|
|
157
|
+
}, 'git-worktree: credential service dispose');
|
|
144
158
|
const deps = () => ({
|
|
145
159
|
exec: childProcessExec,
|
|
146
160
|
sectionRootDir: () => readVolatile(currentConfig().rootDir),
|
|
@@ -150,6 +164,8 @@ export function apply(ctx, config = {}) {
|
|
|
150
164
|
sectionCnAccelerateUrl: () => readVolatile(currentConfig().cnAccelerateUrl),
|
|
151
165
|
home: () => homedir(),
|
|
152
166
|
envHome: () => process.env.DSH_HOME,
|
|
167
|
+
credentialsService: credentials,
|
|
168
|
+
refreshShellBridge: () => credentials.refreshShellEnv(),
|
|
153
169
|
});
|
|
154
170
|
// In dsh 0.1.7+, the settings service projects volatile Config fields into forms.
|
|
155
171
|
// We configure auto: false to inform the host that this plugin provides its own
|
|
@@ -163,14 +179,15 @@ export function apply(ctx, config = {}) {
|
|
|
163
179
|
// unless the user explicitly asked to see it.
|
|
164
180
|
ctx.inject(['tools'], (toolsCtx) => {
|
|
165
181
|
const tools = toolsCtx.tools;
|
|
166
|
-
toolsCtx.effect(() => tools.register(gitCredentialsTool({ home: () => homedir(), envHome: () => process.env.DSH_HOME })), 'git-worktree: git_credentials tool');
|
|
167
|
-
// The panel routes
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
182
|
+
toolsCtx.effect(() => tools.register(gitCredentialsTool({ home: () => homedir(), envHome: () => process.env.DSH_HOME, credentialsService: credentials })), 'git-worktree: git_credentials tool');
|
|
183
|
+
// The panel routes inject the stored credentials into the requests the
|
|
184
|
+
// browser half makes; a shell command is served by the environment bridge
|
|
185
|
+
// (see `shell-bridge.ts`) instead. This tool is the deterministic path for
|
|
186
|
+
// a session: it runs one network subcommand with the `-c
|
|
187
|
+
// http.<url>.extraHeader` prefix, so the command authenticates as the
|
|
188
|
+
// configured account and the secret never enters the conversation — even
|
|
189
|
+
// when the bridge is missing (an older host, a shell outside the plugin).
|
|
190
|
+
toolsCtx.effect(() => tools.register(gitNetworkTool({ home: () => homedir(), envHome: () => process.env.DSH_HOME, credentialsService: credentials })), 'git-worktree: git_network tool');
|
|
174
191
|
});
|
|
175
192
|
ctx.inject(['webServer'], (webCtx) => {
|
|
176
193
|
/** Send one outcome as JSON; no-store because repo facts are point-in-time. */
|
package/lib/network-tool.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The model-facing `git_network` tool (host half).
|
|
3
3
|
*
|
|
4
|
-
* The credentials panel stores one token per remote host
|
|
5
|
-
* fetch / pull / push routes
|
|
6
|
-
* http.<url>.extraHeader
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
12
|
*
|
|
13
13
|
* It is deliberately narrower than a shell wrapper: a fixed subcommand
|
|
14
14
|
* allow-list (fetch / pull / push / ls-remote), no force/delete refspecs, no
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @module dsh-git-branch/network-tool
|
|
21
21
|
*/
|
|
22
22
|
import { type CredentialStore, type GitCredential } from './credentials.js';
|
|
23
|
+
import type { CredentialService } from './credential-service.js';
|
|
23
24
|
import { type Exec } from './git.js';
|
|
24
25
|
/** Model-facing tool name. */
|
|
25
26
|
export declare const GIT_NETWORK_TOOL_NAME = "git_network";
|
|
@@ -66,6 +67,9 @@ export interface GitNetworkToolDeps {
|
|
|
66
67
|
readonly envHome: () => string | undefined;
|
|
67
68
|
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
68
69
|
readonly store?: CredentialStore;
|
|
70
|
+
/** The single credential entry point (decision 0034); when wired its store
|
|
71
|
+
* is shared with the routes and the shell bridge. */
|
|
72
|
+
readonly credentialsService?: CredentialService;
|
|
69
73
|
/** Process seam; the real `git` over node:child_process when absent. */
|
|
70
74
|
readonly exec?: Exec;
|
|
71
75
|
}
|
package/lib/network-tool.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The model-facing `git_network` tool (host half).
|
|
3
3
|
*
|
|
4
|
-
* The credentials panel stores one token per remote host
|
|
5
|
-
* fetch / pull / push routes
|
|
6
|
-
* http.<url>.extraHeader
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
12
|
*
|
|
13
13
|
* It is deliberately narrower than a shell wrapper: a fixed subcommand
|
|
14
14
|
* allow-list (fetch / pull / push / ls-remote), no force/delete refspecs, no
|
|
@@ -162,14 +162,18 @@ export function renderGitNetwork(value) {
|
|
|
162
162
|
* @returns a definition ready for `ctx.tools.register`.
|
|
163
163
|
*/
|
|
164
164
|
export function gitNetworkTool(deps) {
|
|
165
|
-
const storeOf = () => deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
165
|
+
const storeOf = () => deps.credentialsService?.store ?? deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
166
166
|
const exec = deps.exec ?? childProcessExec;
|
|
167
167
|
return {
|
|
168
168
|
name: GIT_NETWORK_TOOL_NAME,
|
|
169
169
|
description: 'Run ONE git network command (fetch, pull, push or ls-remote) inside a repository with the '
|
|
170
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.
|
|
172
|
-
+ '
|
|
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 '
|
|
173
177
|
+ 'arguments subcommand-first, for example ["push"], ["fetch", "--all", "--prune"] or '
|
|
174
178
|
+ '["push", "origin", "main"]. Force/delete refspecs, --force/--delete/--mirror/--prune and '
|
|
175
179
|
+ '--upload-pack/--receive-pack style options are rejected by design. The secret itself never appears in the '
|
package/lib/routes.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { type CopyFilesSeams } from './copy-files.js';
|
|
6
6
|
import { type CredentialStore } from './credentials.js';
|
|
7
|
+
import type { CredentialService } from './credential-service.js';
|
|
7
8
|
import { type DirExists, type ExcludeSeams, type Exec } from './git.js';
|
|
8
9
|
import type { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateTagResult, CreateWorktreeResult, CredentialOAuthPollResult, CredentialOAuthStartResult, CredentialsResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, PushTagResult, RemoveWorktreeResult, RemotesResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
|
|
9
10
|
/** Everything the handlers need from the host half. */
|
|
@@ -44,6 +45,12 @@ export interface RouteDeps {
|
|
|
44
45
|
/** Global per-host credential store. Absent = the fs-backed store at the
|
|
45
46
|
* resolved dsh home; tests substitute an in-memory one. */
|
|
46
47
|
credentialsStore?: CredentialStore;
|
|
48
|
+
/** The single credential entry point (decision 0034): when wired, it owns
|
|
49
|
+
* the store and both injection channels, and the seams below are ignored. */
|
|
50
|
+
credentialsService?: CredentialService;
|
|
51
|
+
/** Re-derive the shell credential bridge after the store changed (absent =
|
|
52
|
+
* a host that does not provide the bridge; tests omit it). */
|
|
53
|
+
refreshShellBridge?: () => Promise<void>;
|
|
47
54
|
}
|
|
48
55
|
/** Real fs-backed one-level directory listing (names only). Plain FILES are
|
|
49
56
|
* filtered out — the storage root can carry strays (a legacy settings.json
|
package/lib/routes.js
CHANGED
|
@@ -53,10 +53,11 @@ function accelPrefix(deps) {
|
|
|
53
53
|
const url = deps.sectionCnAccelerateUrl?.() ?? ACCEL_DEFAULT_URL;
|
|
54
54
|
return accelerateUrlError(url) === undefined ? normalizeAccelerateUrl(url) : undefined;
|
|
55
55
|
}
|
|
56
|
-
/** The global credential store: the
|
|
57
|
-
* resolved dsh home
|
|
56
|
+
/** The global credential store: the service's shared instance (decision 0034),
|
|
57
|
+
* else the wired store, else the fs-backed file at the resolved dsh home
|
|
58
|
+
* (`<dshHome>/git-credentials.json`). */
|
|
58
59
|
function credentialStore(deps) {
|
|
59
|
-
return deps.credentialsStore ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
60
|
+
return deps.credentialsService?.store ?? deps.credentialsStore ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
62
63
|
* `-c http.<url>.extraHeader` pairs for every stored credential, ready to
|
|
@@ -66,6 +67,8 @@ function credentialStore(deps) {
|
|
|
66
67
|
* route is where the file problem surfaces.
|
|
67
68
|
*/
|
|
68
69
|
async function authArgsFor(deps) {
|
|
70
|
+
if (deps.credentialsService !== undefined)
|
|
71
|
+
return deps.credentialsService.authConfigArgs();
|
|
69
72
|
try {
|
|
70
73
|
return authConfigArgs(await credentialStore(deps).load());
|
|
71
74
|
}
|
|
@@ -73,6 +76,24 @@ async function authArgsFor(deps) {
|
|
|
73
76
|
return [];
|
|
74
77
|
}
|
|
75
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Re-derive the shell credential bridge after a credential changed, so a
|
|
81
|
+
* message sent in the same session can `git push` with the token the user just
|
|
82
|
+
* saved. A bridge failure must never fail the save itself: the store write has
|
|
83
|
+
* already landed, and the worst case is a stale environment until the next
|
|
84
|
+
* save (an added host is not reachable, a deleted one may still be).
|
|
85
|
+
*/
|
|
86
|
+
async function refreshBridge(deps) {
|
|
87
|
+
const refresh = deps.credentialsService?.refreshShellEnv ?? deps.refreshShellBridge;
|
|
88
|
+
if (refresh === undefined)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
await refresh();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Deliberately swallowed; see the contract above.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
76
97
|
/** Upper bound on distinct paths one /group request may probe. */
|
|
77
98
|
const GROUP_PATHS_LIMIT = 256;
|
|
78
99
|
/** Probes per in-flight batch — polite on Windows, where each git call is a
|
|
@@ -1362,6 +1383,7 @@ export async function handleCredentialsSave(deps, body) {
|
|
|
1362
1383
|
updatedAt: Date.now(),
|
|
1363
1384
|
});
|
|
1364
1385
|
await store.save(next);
|
|
1386
|
+
await refreshBridge(deps);
|
|
1365
1387
|
const result = { credentials: sortCredentials(next).map(credentialView) };
|
|
1366
1388
|
return { status: 200, body: result };
|
|
1367
1389
|
}
|
|
@@ -1389,6 +1411,7 @@ export async function handleCredentialsDelete(deps, body) {
|
|
|
1389
1411
|
if (next.length === entries.length)
|
|
1390
1412
|
return fail(400, `no credential is stored for "${host}"`);
|
|
1391
1413
|
await store.save(next);
|
|
1414
|
+
await refreshBridge(deps);
|
|
1392
1415
|
const result = { credentials: sortCredentials(next).map(credentialView) };
|
|
1393
1416
|
return { status: 200, body: result };
|
|
1394
1417
|
}
|
|
@@ -1495,6 +1518,7 @@ export async function handleCredentialsOAuthPoll(deps, body) {
|
|
|
1495
1518
|
updatedAt: Date.now(),
|
|
1496
1519
|
});
|
|
1497
1520
|
await store.save(next);
|
|
1521
|
+
await refreshBridge(deps);
|
|
1498
1522
|
const done = { status: 'done', credentials: sortCredentials(next).map(credentialView) };
|
|
1499
1523
|
return { status: 200, body: done };
|
|
1500
1524
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell credential bridge (host half) — session gitconfig channel. The
|
|
3
|
+
* credential panel stores one token per remote host, but 0029 could only
|
|
4
|
+
* inject it into the panel's own routes and the `git_network` tool; a plain
|
|
5
|
+
* `git push` run in a shell authenticated as whatever the machine's
|
|
6
|
+
* credential helper held — often a different account — or hung on an
|
|
7
|
+
* interactive prompt nobody could answer.
|
|
8
|
+
*
|
|
9
|
+
* 0032 closed that gap with `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/
|
|
10
|
+
* `GIT_CONFIG_VALUE_n` environment entries. That channel is dead on a DSH
|
|
11
|
+
* host: the subprocess environment scrubber
|
|
12
|
+
* (`@deepseek-ai/dsh-subprocess`, `SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i`
|
|
13
|
+
* plus every `DSH_*` name) strips the `GIT_CONFIG_KEY_*` names and keeps
|
|
14
|
+
* `GIT_CONFIG_COUNT`/`VALUE_*`, so git starts with a half-parsed config and
|
|
15
|
+
* dies with `fatal: unable to parse command-line config` — every git command
|
|
16
|
+
* in the shell, not only the networked ones (0034).
|
|
17
|
+
*
|
|
18
|
+
* The replacement writes ONE generated gitconfig per host process and points
|
|
19
|
+
* git at it with `GIT_CONFIG_GLOBAL` — a name the scrubber keeps. The file:
|
|
20
|
+
*
|
|
21
|
+
* 1. `[include]`s the user's own `~/.gitconfig` (read-only; 0021's "never
|
|
22
|
+
* write the user's git config files" stands) so identity, proxies and
|
|
23
|
+
* `http.sslBackend` keep working;
|
|
24
|
+
* 2. resets the whole `credential.helper` list to empty, cutting the machine
|
|
25
|
+
* chain (`helper-selector` → GCM) and any helper the included user config
|
|
26
|
+
* carries (the local `gh` helper being the motivating case);
|
|
27
|
+
* 3. per stored host, resets that URL scope and then installs the bundled
|
|
28
|
+
* helper (`helpers/git-credential-bridge.js`) as a `!`-shell command.
|
|
29
|
+
*
|
|
30
|
+
* A host with no stored credential then fails at once ("terminal prompts
|
|
31
|
+
* disabled") instead of reaching the machine chain, whose interactive GUI
|
|
32
|
+
* dialog nobody in an agent shell can answer. `GIT_TERMINAL_PROMPT=0` and
|
|
33
|
+
* `GCM_INTERACTIVE=never` ride along as the last line of defence.
|
|
34
|
+
*
|
|
35
|
+
* Environment contract (all names survive the scrubber — no KEY/PASSWORD/
|
|
36
|
+
* SECRET/TOKEN substring, no `DSH_` prefix):
|
|
37
|
+
* - `GIT_CONFIG_GLOBAL` — the generated session config (0034).
|
|
38
|
+
* - `DGB_BRIDGE_HELPER` — credential file path for the helper script.
|
|
39
|
+
* - `GIT_TERMINAL_PROMPT` / `GCM_INTERACTIVE` — fail fast, never prompt.
|
|
40
|
+
*
|
|
41
|
+
* The file is re-derived whenever the credential store changes
|
|
42
|
+
* (`src/routes.ts` calls the bridge after every save / delete / sign-in).
|
|
43
|
+
*/
|
|
44
|
+
import { type CredentialStore } from './credentials.js';
|
|
45
|
+
/** Env var carrying the credential file path to the helper script. */
|
|
46
|
+
export declare const BRIDGE_HELPER_ENV = "DGB_BRIDGE_HELPER";
|
|
47
|
+
/** Env var carrying a debug log path to the helper script (unset by default). */
|
|
48
|
+
export declare const BRIDGE_DEBUG_ENV = "DGB_BRIDGE_DEBUG";
|
|
49
|
+
/** Env var pointing git at the generated session configuration file. */
|
|
50
|
+
export declare const SESSION_GITCONFIG_ENV = "GIT_CONFIG_GLOBAL";
|
|
51
|
+
/** Host-side seams the bridge reads through (tests substitute all of them). */
|
|
52
|
+
export interface ShellBridgeDeps {
|
|
53
|
+
/** User home directory seam. */
|
|
54
|
+
readonly home: () => string;
|
|
55
|
+
/** `$DSH_HOME` seam (blank/undefined = unset). */
|
|
56
|
+
readonly envHome: () => string | undefined;
|
|
57
|
+
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
58
|
+
readonly store?: CredentialStore;
|
|
59
|
+
/** The environment object to mutate; `process.env` when absent. */
|
|
60
|
+
readonly env?: Record<string, string | undefined>;
|
|
61
|
+
/** Absolute helper script path; the compiled sibling under `helpers/` when
|
|
62
|
+
* absent. */
|
|
63
|
+
readonly helperPath?: string;
|
|
64
|
+
/** Node executable that runs the helper; `process.execPath` when absent. */
|
|
65
|
+
readonly nodePath?: string;
|
|
66
|
+
/** Directory for the session config; the OS temp dir when absent. */
|
|
67
|
+
readonly configDir?: () => string;
|
|
68
|
+
/** Exact session config path (tests); `<configDir>/dsh-git-branch-<pid>.gitconfig` when absent. */
|
|
69
|
+
readonly configPath?: string;
|
|
70
|
+
/** User global config the session file includes; `<home>/.gitconfig` when absent. */
|
|
71
|
+
readonly userGitconfig?: string;
|
|
72
|
+
}
|
|
73
|
+
/** The bridge's operations: re-derive the environment, and undo it. */
|
|
74
|
+
export interface ShellBridge {
|
|
75
|
+
/** Read the credential store and rewrite the injected environment.
|
|
76
|
+
* A store that cannot be read drops every URL-scoped helper (no host can be
|
|
77
|
+
* bridged), but keeps the global reset, so shell git still fails fast
|
|
78
|
+
* instead of opening an interactive credential dialog. */
|
|
79
|
+
readonly apply: () => Promise<void>;
|
|
80
|
+
/** Remove the injected environment and delete the session config file. */
|
|
81
|
+
readonly dispose: () => Promise<void>;
|
|
82
|
+
/** The session config path this bridge writes. */
|
|
83
|
+
readonly configPath: () => string;
|
|
84
|
+
}
|
|
85
|
+
/** One ordered environment assignment. */
|
|
86
|
+
export type BridgeAssignment = readonly [key: string, value: string];
|
|
87
|
+
/**
|
|
88
|
+
* Quote one executable or path for git's `!` shell command. Backslashes are
|
|
89
|
+
* normalized to forward slashes first: git runs `!` helpers through `sh`,
|
|
90
|
+
* where `C:\\…` would be read as escape sequences.
|
|
91
|
+
* @param value - an absolute path.
|
|
92
|
+
*/
|
|
93
|
+
export declare function shellQuote(value: string): string;
|
|
94
|
+
/**
|
|
95
|
+
* The helper command git runs for one configured host: a `!`-prefixed shell
|
|
96
|
+
* command, because git does not word-split a helper value by itself (a bare
|
|
97
|
+
* `node "C:/…/bridge.js"` is looked up as the program `git-credential-node`).
|
|
98
|
+
* @param nodePath - the node executable.
|
|
99
|
+
* @param helperPath - the compiled helper script.
|
|
100
|
+
*/
|
|
101
|
+
export declare function bridgeCommand(nodePath: string, helperPath: string): string;
|
|
102
|
+
/**
|
|
103
|
+
* Escape one value for a gitconfig line: a literal `"` becomes `\"`, which is
|
|
104
|
+
* how a quoted path survives git's config parser and still reaches `sh`
|
|
105
|
+
* intact. Without this the quotes are eaten and `sh` splits
|
|
106
|
+
* `C:/Program Files/…` on the space.
|
|
107
|
+
* @param value - the raw helper command.
|
|
108
|
+
*/
|
|
109
|
+
export declare function gitconfigEscape(value: string): string;
|
|
110
|
+
/**
|
|
111
|
+
* The session gitconfig body for the given hosts: the user's own config
|
|
112
|
+
* included (read-only), the whole machine helper list reset to empty, and per
|
|
113
|
+
* host a URL-scoped reset followed by the bridge command. The empty value
|
|
114
|
+
* before each command is what cuts the included `gh` helper (and any other
|
|
115
|
+
* helper the user configures for that URL) out of that scope.
|
|
116
|
+
* @param hosts - normalized hosts with a stored credential.
|
|
117
|
+
* @param options - the node/helper pair and the user config to include.
|
|
118
|
+
*/
|
|
119
|
+
export declare function sessionGitconfigBody(hosts: readonly string[], options: {
|
|
120
|
+
readonly nodePath: string;
|
|
121
|
+
readonly helperPath: string;
|
|
122
|
+
readonly userGitconfig?: string;
|
|
123
|
+
}): string;
|
|
124
|
+
/**
|
|
125
|
+
* The environment assignments pointing git at the session config: the config
|
|
126
|
+
* path itself, the credential file the helper reads, and the two fail-fast
|
|
127
|
+
* switches. `hosts` live in the file now, not in the environment.
|
|
128
|
+
* @param options - the session config path and the credential file.
|
|
129
|
+
*/
|
|
130
|
+
export declare function shellBridgeEnv(options: {
|
|
131
|
+
readonly configPath: string;
|
|
132
|
+
readonly credentialFile: string;
|
|
133
|
+
}): readonly BridgeAssignment[];
|
|
134
|
+
/**
|
|
135
|
+
* Build the bridge over one host process. Every {@link ShellBridge.apply}
|
|
136
|
+
* rewrites the session config for the current store and re-points the
|
|
137
|
+
* environment at it, so a deleted host loses its helper scope just like a
|
|
138
|
+
* freshly added one gains it. A config file that cannot be written leaves
|
|
139
|
+
* `GIT_CONFIG_GLOBAL` untouched (git then runs with the machine config —
|
|
140
|
+
* unauthenticated at worst) while the fail-fast switches still go out.
|
|
141
|
+
* @param deps - host seams.
|
|
142
|
+
*/
|
|
143
|
+
export declare function createShellBridge(deps: ShellBridgeDeps): ShellBridge;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell credential bridge (host half) — session gitconfig channel. The
|
|
3
|
+
* credential panel stores one token per remote host, but 0029 could only
|
|
4
|
+
* inject it into the panel's own routes and the `git_network` tool; a plain
|
|
5
|
+
* `git push` run in a shell authenticated as whatever the machine's
|
|
6
|
+
* credential helper held — often a different account — or hung on an
|
|
7
|
+
* interactive prompt nobody could answer.
|
|
8
|
+
*
|
|
9
|
+
* 0032 closed that gap with `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/
|
|
10
|
+
* `GIT_CONFIG_VALUE_n` environment entries. That channel is dead on a DSH
|
|
11
|
+
* host: the subprocess environment scrubber
|
|
12
|
+
* (`@deepseek-ai/dsh-subprocess`, `SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i`
|
|
13
|
+
* plus every `DSH_*` name) strips the `GIT_CONFIG_KEY_*` names and keeps
|
|
14
|
+
* `GIT_CONFIG_COUNT`/`VALUE_*`, so git starts with a half-parsed config and
|
|
15
|
+
* dies with `fatal: unable to parse command-line config` — every git command
|
|
16
|
+
* in the shell, not only the networked ones (0034).
|
|
17
|
+
*
|
|
18
|
+
* The replacement writes ONE generated gitconfig per host process and points
|
|
19
|
+
* git at it with `GIT_CONFIG_GLOBAL` — a name the scrubber keeps. The file:
|
|
20
|
+
*
|
|
21
|
+
* 1. `[include]`s the user's own `~/.gitconfig` (read-only; 0021's "never
|
|
22
|
+
* write the user's git config files" stands) so identity, proxies and
|
|
23
|
+
* `http.sslBackend` keep working;
|
|
24
|
+
* 2. resets the whole `credential.helper` list to empty, cutting the machine
|
|
25
|
+
* chain (`helper-selector` → GCM) and any helper the included user config
|
|
26
|
+
* carries (the local `gh` helper being the motivating case);
|
|
27
|
+
* 3. per stored host, resets that URL scope and then installs the bundled
|
|
28
|
+
* helper (`helpers/git-credential-bridge.js`) as a `!`-shell command.
|
|
29
|
+
*
|
|
30
|
+
* A host with no stored credential then fails at once ("terminal prompts
|
|
31
|
+
* disabled") instead of reaching the machine chain, whose interactive GUI
|
|
32
|
+
* dialog nobody in an agent shell can answer. `GIT_TERMINAL_PROMPT=0` and
|
|
33
|
+
* `GCM_INTERACTIVE=never` ride along as the last line of defence.
|
|
34
|
+
*
|
|
35
|
+
* Environment contract (all names survive the scrubber — no KEY/PASSWORD/
|
|
36
|
+
* SECRET/TOKEN substring, no `DSH_` prefix):
|
|
37
|
+
* - `GIT_CONFIG_GLOBAL` — the generated session config (0034).
|
|
38
|
+
* - `DGB_BRIDGE_HELPER` — credential file path for the helper script.
|
|
39
|
+
* - `GIT_TERMINAL_PROMPT` / `GCM_INTERACTIVE` — fail fast, never prompt.
|
|
40
|
+
*
|
|
41
|
+
* The file is re-derived whenever the credential store changes
|
|
42
|
+
* (`src/routes.ts` calls the bridge after every save / delete / sign-in).
|
|
43
|
+
*/
|
|
44
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { tmpdir } from 'node:os';
|
|
46
|
+
import { dirname, join } from 'node:path';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { credentialFilePath, createCredentialStore, } from './credentials.js';
|
|
49
|
+
/** Env var carrying the credential file path to the helper script. */
|
|
50
|
+
export const BRIDGE_HELPER_ENV = 'DGB_BRIDGE_HELPER';
|
|
51
|
+
/** Env var carrying a debug log path to the helper script (unset by default). */
|
|
52
|
+
export const BRIDGE_DEBUG_ENV = 'DGB_BRIDGE_DEBUG';
|
|
53
|
+
/** Env var pointing git at the generated session configuration file. */
|
|
54
|
+
export const SESSION_GITCONFIG_ENV = 'GIT_CONFIG_GLOBAL';
|
|
55
|
+
/**
|
|
56
|
+
* Quote one executable or path for git's `!` shell command. Backslashes are
|
|
57
|
+
* normalized to forward slashes first: git runs `!` helpers through `sh`,
|
|
58
|
+
* where `C:\\…` would be read as escape sequences.
|
|
59
|
+
* @param value - an absolute path.
|
|
60
|
+
*/
|
|
61
|
+
export function shellQuote(value) {
|
|
62
|
+
return `"${value.replace(/\\/g, '/')}"`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The helper command git runs for one configured host: a `!`-prefixed shell
|
|
66
|
+
* command, because git does not word-split a helper value by itself (a bare
|
|
67
|
+
* `node "C:/…/bridge.js"` is looked up as the program `git-credential-node`).
|
|
68
|
+
* @param nodePath - the node executable.
|
|
69
|
+
* @param helperPath - the compiled helper script.
|
|
70
|
+
*/
|
|
71
|
+
export function bridgeCommand(nodePath, helperPath) {
|
|
72
|
+
return `!${shellQuote(nodePath)} ${shellQuote(helperPath)}`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Escape one value for a gitconfig line: a literal `"` becomes `\"`, which is
|
|
76
|
+
* how a quoted path survives git's config parser and still reaches `sh`
|
|
77
|
+
* intact. Without this the quotes are eaten and `sh` splits
|
|
78
|
+
* `C:/Program Files/…` on the space.
|
|
79
|
+
* @param value - the raw helper command.
|
|
80
|
+
*/
|
|
81
|
+
export function gitconfigEscape(value) {
|
|
82
|
+
return value.replace(/"/g, '\\"');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The session gitconfig body for the given hosts: the user's own config
|
|
86
|
+
* included (read-only), the whole machine helper list reset to empty, and per
|
|
87
|
+
* host a URL-scoped reset followed by the bridge command. The empty value
|
|
88
|
+
* before each command is what cuts the included `gh` helper (and any other
|
|
89
|
+
* helper the user configures for that URL) out of that scope.
|
|
90
|
+
* @param hosts - normalized hosts with a stored credential.
|
|
91
|
+
* @param options - the node/helper pair and the user config to include.
|
|
92
|
+
*/
|
|
93
|
+
export function sessionGitconfigBody(hosts, options) {
|
|
94
|
+
const unique = [...new Set(hosts)];
|
|
95
|
+
const lines = [
|
|
96
|
+
'# Generated by dsh-git-branch-manage (shell credential bridge, decision 0034). Do not edit.',
|
|
97
|
+
'# Regenerated whenever the credential store changes; safe to delete.',
|
|
98
|
+
];
|
|
99
|
+
if (options.userGitconfig !== undefined) {
|
|
100
|
+
// Read-only include: the user\'s identity, proxies and TLS settings keep
|
|
101
|
+
// working, and nothing is ever written back to this file.
|
|
102
|
+
lines.push('[include]', `\tpath = ${options.userGitconfig.replace(/\\/g, '/')}`);
|
|
103
|
+
}
|
|
104
|
+
// Reset the WHOLE helper list first, even when no host is stored. Without
|
|
105
|
+
// it, any host without a stored credential still reaches the machine chain
|
|
106
|
+
// (`helper-selector` → GCM), which raises a GUI prompt no agent shell can
|
|
107
|
+
// answer. With it, such a host fails at once with "terminal prompts
|
|
108
|
+
// disabled", and the URL-scoped pairs below are the only way credentials
|
|
109
|
+
// get back into a shell.
|
|
110
|
+
lines.push('[credential]', '\thelper =');
|
|
111
|
+
const command = gitconfigEscape(bridgeCommand(options.nodePath, options.helperPath));
|
|
112
|
+
for (const host of unique) {
|
|
113
|
+
lines.push(`[credential "https://${host}/"]`,
|
|
114
|
+
// Empty first: it resets whatever helper list the included user config
|
|
115
|
+
// accumulated for this URL (the local `gh` helper), or git would answer
|
|
116
|
+
// with that account before the bridge is ever consulted.
|
|
117
|
+
'\thelper =', `\thelper = ${command}`);
|
|
118
|
+
}
|
|
119
|
+
return `${lines.join('\n')}\n`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The environment assignments pointing git at the session config: the config
|
|
123
|
+
* path itself, the credential file the helper reads, and the two fail-fast
|
|
124
|
+
* switches. `hosts` live in the file now, not in the environment.
|
|
125
|
+
* @param options - the session config path and the credential file.
|
|
126
|
+
*/
|
|
127
|
+
export function shellBridgeEnv(options) {
|
|
128
|
+
return [
|
|
129
|
+
[SESSION_GITCONFIG_ENV, options.configPath],
|
|
130
|
+
[BRIDGE_HELPER_ENV, options.credentialFile],
|
|
131
|
+
// Fail fast instead of parking a shell on a prompt or a GUI dialog.
|
|
132
|
+
['GIT_TERMINAL_PROMPT', '0'],
|
|
133
|
+
['GCM_INTERACTIVE', 'never'],
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build the bridge over one host process. Every {@link ShellBridge.apply}
|
|
138
|
+
* rewrites the session config for the current store and re-points the
|
|
139
|
+
* environment at it, so a deleted host loses its helper scope just like a
|
|
140
|
+
* freshly added one gains it. A config file that cannot be written leaves
|
|
141
|
+
* `GIT_CONFIG_GLOBAL` untouched (git then runs with the machine config —
|
|
142
|
+
* unauthenticated at worst) while the fail-fast switches still go out.
|
|
143
|
+
* @param deps - host seams.
|
|
144
|
+
*/
|
|
145
|
+
export function createShellBridge(deps) {
|
|
146
|
+
const env = deps.env ?? process.env;
|
|
147
|
+
const configPath = deps.configPath
|
|
148
|
+
?? join((deps.configDir ?? tmpdir)(), `dsh-git-branch-${String(process.pid)}.gitconfig`);
|
|
149
|
+
let applied = [];
|
|
150
|
+
const credentialFile = () => credentialFilePath(deps.home(), deps.envHome());
|
|
151
|
+
/** Write the session config atomically with owner-only permissions. */
|
|
152
|
+
const writeConfig = (body) => {
|
|
153
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
154
|
+
const partial = `${configPath}.tmp`;
|
|
155
|
+
writeFileSync(partial, body, { encoding: 'utf8', mode: 0o600 });
|
|
156
|
+
renameSync(partial, configPath);
|
|
157
|
+
try {
|
|
158
|
+
chmodSync(configPath, 0o600);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Best effort: Windows ignores the mode, the file holds no secret.
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
configPath: () => configPath,
|
|
166
|
+
async apply() {
|
|
167
|
+
let hosts = [];
|
|
168
|
+
try {
|
|
169
|
+
hosts = (await (deps.store ?? createCredentialStore(credentialFile())).load()).map(entry => entry.host);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// An unreadable store means no host can be bridged. The global helper
|
|
173
|
+
// reset still goes out, so shell git fails fast rather than popping a
|
|
174
|
+
// credential dialog nobody can answer.
|
|
175
|
+
hosts = [];
|
|
176
|
+
}
|
|
177
|
+
const helperPath = deps.helperPath
|
|
178
|
+
?? fileURLToPath(new URL('./helpers/git-credential-bridge.js', import.meta.url));
|
|
179
|
+
const body = sessionGitconfigBody(hosts, {
|
|
180
|
+
nodePath: deps.nodePath ?? process.execPath,
|
|
181
|
+
helperPath,
|
|
182
|
+
userGitconfig: deps.userGitconfig ?? join(deps.home(), '.gitconfig'),
|
|
183
|
+
});
|
|
184
|
+
let assignments = [
|
|
185
|
+
['GIT_TERMINAL_PROMPT', '0'],
|
|
186
|
+
['GCM_INTERACTIVE', 'never'],
|
|
187
|
+
];
|
|
188
|
+
try {
|
|
189
|
+
writeConfig(body);
|
|
190
|
+
assignments = shellBridgeEnv({ configPath, credentialFile: credentialFile() });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// The session config could not be written. Do not point
|
|
194
|
+
// GIT_CONFIG_GLOBAL at a missing file (that would drop the reset and
|
|
195
|
+
// let the machine chain pop a dialog); only the fail-fast switches
|
|
196
|
+
// remain, so a shell git runs unauthenticated but never hangs.
|
|
197
|
+
}
|
|
198
|
+
for (const key of applied)
|
|
199
|
+
delete env[key];
|
|
200
|
+
applied = [];
|
|
201
|
+
for (const [key, value] of assignments) {
|
|
202
|
+
env[key] = value;
|
|
203
|
+
applied.push(key);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
async dispose() {
|
|
207
|
+
for (const key of applied)
|
|
208
|
+
delete env[key];
|
|
209
|
+
applied = [];
|
|
210
|
+
try {
|
|
211
|
+
rmSync(configPath, { force: true });
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Best effort: a leftover file holds no secret and apply rewrites it.
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
package/lib/tool.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* @module dsh-git-branch/tool
|
|
16
16
|
*/
|
|
17
17
|
import { type CredentialStore } from './credentials.js';
|
|
18
|
+
import type { CredentialService } from './credential-service.js';
|
|
18
19
|
/** Model-facing tool name. */
|
|
19
20
|
export declare const GIT_CREDENTIALS_TOOL_NAME = "git_credentials";
|
|
20
21
|
/** One text block in a tool result. */
|
|
@@ -58,6 +59,9 @@ export interface GitCredentialsToolDeps {
|
|
|
58
59
|
readonly envHome: () => string | undefined;
|
|
59
60
|
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
60
61
|
readonly store?: CredentialStore;
|
|
62
|
+
/** The single credential entry point (decision 0034); when wired its store
|
|
63
|
+
* is shared with the routes and the shell bridge. */
|
|
64
|
+
readonly credentialsService?: CredentialService;
|
|
61
65
|
}
|
|
62
66
|
/** A tool definition as `ctx.tools.register` accepts it. */
|
|
63
67
|
export interface GitCredentialsTool {
|
package/lib/tool.js
CHANGED
|
@@ -48,7 +48,7 @@ export function renderGitCredentials(value) {
|
|
|
48
48
|
* @returns a definition ready for `ctx.tools.register`.
|
|
49
49
|
*/
|
|
50
50
|
export function gitCredentialsTool(deps) {
|
|
51
|
-
const storeOf = () => deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
51
|
+
const storeOf = () => deps.credentialsService?.store ?? deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
52
52
|
/** Read the store, folding a read failure into the result value. */
|
|
53
53
|
const read = async (args) => {
|
|
54
54
|
const store = storeOf();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-branch-manage",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "提供 git 分支与 worktree 管理:输入框工具栏显示当前分支、可原地切换;支持 IDEA 风格分支选择器、远程分支检出、右键操作菜单(新建/重命名/删除/复制)、推送与更新当前分支,以及把分支隔离到独立 worktree 工作区,并管理其创建与删除。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"!lib/**/*.map",
|
|
46
46
|
"cordis.patch.yml",
|
|
47
47
|
"README.md",
|
|
48
|
+
"CHANGELOG.md",
|
|
48
49
|
"locale/*.json",
|
|
49
50
|
"icon.svg"
|
|
50
51
|
],
|