@agentconnect.md/daemon 1.0.0-rc.297 → 1.0.0-rc.299

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/{acp-host-03mQVw2Q.js → acp-host-DezWSmEG.js} +2 -2
  2. package/dist/{acp-host-03mQVw2Q.js.map → acp-host-DezWSmEG.js.map} +1 -1
  3. package/dist/{agent-list-DHFLd9ux.js → agent-list-Da9Osn4O.js} +2 -2
  4. package/dist/{agent-list-DHFLd9ux.js.map → agent-list-Da9Osn4O.js.map} +1 -1
  5. package/dist/{chat-IRJd4_lj.js → chat-D8QIIHor.js} +3 -3
  6. package/dist/{chat-IRJd4_lj.js.map → chat-D8QIIHor.js.map} +1 -1
  7. package/dist/{load-agents-8AzZXdVx.js → dist-Cq1Kimg_.js} +18 -399
  8. package/dist/dist-Cq1Kimg_.js.map +1 -0
  9. package/dist/gh-token-BfPlYQav.js +97 -0
  10. package/dist/gh-token-BfPlYQav.js.map +1 -0
  11. package/dist/{git-credential-DLRF9bOx.js → git-credential-DhWn1IBJ.js} +26 -8
  12. package/dist/git-credential-DhWn1IBJ.js.map +1 -0
  13. package/dist/{gitcred-server-Dhp-tyEI.js → gitcred-server-KXxLyYxc.js} +107 -55
  14. package/dist/gitcred-server-KXxLyYxc.js.map +1 -0
  15. package/dist/index.js +9 -5
  16. package/dist/index.js.map +1 -1
  17. package/dist/load-agents-gTqVkOwl.js +401 -0
  18. package/dist/load-agents-gTqVkOwl.js.map +1 -0
  19. package/dist/{login-xtaBgstv.js → login-BP4bFP-d.js} +4 -3
  20. package/dist/{login-xtaBgstv.js.map → login-BP4bFP-d.js.map} +1 -1
  21. package/dist/{run-foreground-BHU49ZSi.js → run-foreground-B8sw7WKS.js} +216 -143
  22. package/dist/{run-foreground-BHU49ZSi.js.map → run-foreground-B8sw7WKS.js.map} +1 -1
  23. package/dist/run-foreground-jOExu3YR.js +2 -0
  24. package/package.json +1 -1
  25. package/dist/git-credential-DLRF9bOx.js.map +0 -1
  26. package/dist/gitcred-server-Dhp-tyEI.js.map +0 -1
  27. package/dist/load-agents-8AzZXdVx.js.map +0 -1
  28. package/dist/run-foreground-BiYUl931.js +0 -2
@@ -1,5 +1,5 @@
1
1
  import { l as resolveRoot } from "./paths-DEcUO8lC.js";
2
- import { n as gitcredSocketPath } from "./gitcred-server-Dhp-tyEI.js";
2
+ import { n as gitcredSocketPath } from "./gitcred-server-KXxLyYxc.js";
3
3
  import { createConnection } from "node:net";
4
4
  //#region src/cli/git-credential.ts
5
5
  /**
@@ -12,6 +12,13 @@ import { createConnection } from "node:net";
12
12
  * erase → forward to the daemon (invalidate the cached token git just had rejected)
13
13
  * store → no-op
14
14
  *
15
+ * The `path` git sends (useHttpPath=true is part of the injection) ROUTES the
16
+ * request since issue #457: it is parsed to "owner/repo" and forwarded, so an
17
+ * authorized non-workspace repo gets ITS token (multi-repo design decision 5).
18
+ * Path absent/unparseable ⇒ the workspace token, the pre-#457 behavior. This
19
+ * is routing, not authorization — the CP gate decides; token scope enforces
20
+ * the real boundary at GitHub either way.
21
+ *
15
22
  * Exit style: on ANY failure print a human-actionable line to stderr and exit 1
16
23
  * — since #251 surfaces turn failures to end users, this text is user-facing.
17
24
  * NEVER print or log the token outside the protocol response.
@@ -19,11 +26,13 @@ import { createConnection } from "node:net";
19
26
  async function runGitCredential(action, agentId) {
20
27
  if (action === "store") return;
21
28
  const input = parseStdin(await readStdin());
29
+ const repo = input.path !== void 0 ? repoFromPath(input.path) : void 0;
22
30
  if (action === "erase") {
23
31
  await ipc({
24
32
  op: "erase",
25
33
  agentId,
26
- password: input.password
34
+ password: input.password,
35
+ repoFullName: repo
27
36
  }).catch(() => void 0);
28
37
  return;
29
38
  }
@@ -31,23 +40,32 @@ async function runGitCredential(action, agentId) {
31
40
  if (input.host !== void 0 && input.host.toLowerCase() !== "github.com") return;
32
41
  const res = await ipc({
33
42
  op: "get",
34
- agentId
43
+ agentId,
44
+ repoFullName: repo
35
45
  });
36
46
  if (!res.ok || !res.username || !res.password) {
37
- process.stderr.write(`agentconnect: no git credentials for agent ${agentId}: ${res.error ?? "unknown error"}\n(the daemon must be running and connected to the control plane)\n`);
47
+ process.stderr.write(`agentconnect: no git credentials for agent ${agentId}${repo ? ` on ${repo}` : ""}: ${res.error ?? "unknown error"}\n(the daemon must be running and connected to the control plane)\n`);
38
48
  process.exitCode = 1;
39
49
  return;
40
50
  }
41
- if (input.path !== void 0) {
51
+ if (repo !== void 0) {
42
52
  const want = normalizeRepoPath(res.repoFullName ?? "");
43
- const got = normalizeRepoPath(input.path);
44
- if (want && got && got !== want && !got.startsWith(`${want}/`)) process.stderr.write(`agentconnect: note — git asked for ${got} but this agent's repo is ${want} (repo renamed? recreate the agent to re-point it)\n`);
53
+ if (want && want !== repo) process.stderr.write(`agentconnect: note — git asked for ${repo} but the daemon answered for ${want} (daemon predates per-repo credentials?)\n`);
45
54
  }
46
55
  process.stdout.write(`username=${res.username}\npassword=${res.password}\n`);
47
56
  }
48
57
  function normalizeRepoPath(p) {
49
58
  return p.replace(/^\/+/, "").replace(/\.git$/i, "").toLowerCase();
50
59
  }
60
+ /** "owner/repo" from git's credential `path` — tolerates a leading slash, a
61
+ * `.git` suffix, and LFS-ish subpaths (`owner/repo.git/info/lfs`). */
62
+ function repoFromPath(p) {
63
+ const segs = p.replace(/^\/+/, "").split("/");
64
+ const owner = segs[0];
65
+ const repo = segs[1]?.replace(/\.git$/i, "");
66
+ if (!owner || !repo) return void 0;
67
+ return `${owner}/${repo}`.toLowerCase();
68
+ }
51
69
  function parseStdin(text) {
52
70
  const out = {};
53
71
  for (const line of text.split("\n")) {
@@ -96,4 +114,4 @@ function ipc(msg) {
96
114
  //#endregion
97
115
  export { runGitCredential };
98
116
 
99
- //# sourceMappingURL=git-credential-DLRF9bOx.js.map
117
+ //# sourceMappingURL=git-credential-DhWn1IBJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-credential-DhWn1IBJ.js","names":[],"sources":["../src/cli/git-credential.ts"],"sourcesContent":["/**\n * `agentconnect git-credential --agent <id>` — the git credential helper\n * (docs/designs/github-app-git-credentials.md §本地 helper 通道).\n *\n * Speaks git's credential-helper protocol on stdin/stdout and proxies to the\n * daemon's gitcred.sock; the token exists only on the reply pipe. Actions:\n * get → username=x-access-token / password=<token> (host must be github.com)\n * erase → forward to the daemon (invalidate the cached token git just had rejected)\n * store → no-op\n *\n * The `path` git sends (useHttpPath=true is part of the injection) ROUTES the\n * request since issue #457: it is parsed to \"owner/repo\" and forwarded, so an\n * authorized non-workspace repo gets ITS token (multi-repo design decision 5).\n * Path absent/unparseable ⇒ the workspace token, the pre-#457 behavior. This\n * is routing, not authorization — the CP gate decides; token scope enforces\n * the real boundary at GitHub either way.\n *\n * Exit style: on ANY failure print a human-actionable line to stderr and exit 1\n * — since #251 surfaces turn failures to end users, this text is user-facing.\n * NEVER print or log the token outside the protocol response.\n */\nimport { createConnection } from 'node:net'\nimport { resolveRoot } from '../paths.js'\nimport { gitcredSocketPath } from '../cp/gitcred-server.js'\n\ninterface HelperInput {\n protocol?: string\n host?: string\n path?: string\n password?: string\n}\n\nexport async function runGitCredential(action: string, agentId: string): Promise<void> {\n if (action === 'store') return // git offers the accepted credential back — nothing to do\n\n const input = parseStdin(await readStdin())\n const repo = input.path !== undefined ? repoFromPath(input.path) : undefined\n\n if (action === 'erase') {\n // Route the invalidation to the same (agent, repo) key the get used.\n await ipc({ op: 'erase', agentId, password: input.password, repoFullName: repo }).catch(() => undefined) // best-effort\n return\n }\n if (action !== 'get') return // unknown actions are ignored per the helper contract\n\n if (input.host !== undefined && input.host.toLowerCase() !== 'github.com') {\n // Not ours — stay silent so git can try other helpers / fail cleanly.\n return\n }\n\n const res = await ipc({ op: 'get', agentId, repoFullName: repo })\n if (!res.ok || !res.username || !res.password) {\n process.stderr.write(\n `agentconnect: no git credentials for agent ${agentId}${repo ? ` on ${repo}` : ''}: ${res.error ?? 'unknown error'}\\n` +\n `(the daemon must be running and connected to the control plane)\\n`\n )\n process.exitCode = 1\n return\n }\n // Diagnostics-only sanity (the daemon guard already refuses cross-repo\n // grants): a mismatch here means an old daemon answered with the workspace\n // token — GitHub's single-repo token scope still enforces the boundary.\n if (repo !== undefined) {\n const want = normalizeRepoPath(res.repoFullName ?? '')\n if (want && want !== repo) {\n process.stderr.write(\n `agentconnect: note — git asked for ${repo} but the daemon answered for ${want} ` +\n `(daemon predates per-repo credentials?)\\n`\n )\n }\n }\n process.stdout.write(`username=${res.username}\\npassword=${res.password}\\n`)\n}\n\nfunction normalizeRepoPath(p: string): string {\n return p\n .replace(/^\\/+/, '')\n .replace(/\\.git$/i, '')\n .toLowerCase()\n}\n\n/** \"owner/repo\" from git's credential `path` — tolerates a leading slash, a\n * `.git` suffix, and LFS-ish subpaths (`owner/repo.git/info/lfs`). */\nexport function repoFromPath(p: string): string | undefined {\n const segs = p.replace(/^\\/+/, '').split('/')\n const owner = segs[0]\n const repo = segs[1]?.replace(/\\.git$/i, '')\n if (!owner || !repo) return undefined\n return `${owner}/${repo}`.toLowerCase()\n}\n\nfunction parseStdin(text: string): HelperInput {\n const out: Record<string, string> = {}\n for (const line of text.split('\\n')) {\n const eq = line.indexOf('=')\n if (eq > 0) out[line.slice(0, eq)] = line.slice(eq + 1)\n }\n return out\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve) => {\n let buf = ''\n process.stdin.setEncoding('utf8')\n process.stdin.on('data', (c) => (buf += c))\n process.stdin.on('end', () => resolve(buf))\n process.stdin.on('error', () => resolve(buf))\n })\n}\n\ninterface IpcReply {\n ok: boolean\n username?: string\n password?: string\n repoFullName?: string\n error?: string\n}\n\nfunction ipc(msg: unknown): Promise<IpcReply> {\n const path = gitcredSocketPath(resolveRoot())\n return new Promise((resolve) => {\n const sock = createConnection(path)\n let buf = ''\n const fail = (error: string) => resolve({ ok: false, error })\n sock.setTimeout(15_000, () => {\n sock.destroy()\n fail('daemon did not answer in time')\n })\n sock.on('connect', () => sock.write(JSON.stringify(msg) + '\\n'))\n sock.on('data', (c) => {\n buf += c.toString('utf8')\n const nl = buf.indexOf('\\n')\n if (nl === -1) return\n sock.destroy()\n try {\n resolve(JSON.parse(buf.slice(0, nl)) as IpcReply)\n } catch {\n fail('malformed daemon reply')\n }\n })\n sock.on('error', (e) => fail(`cannot reach the daemon socket at ${path}: ${e.message}`))\n })\n}\n"],"mappings":"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"}
@@ -5,20 +5,24 @@ import { createServer } from "node:net";
5
5
  //#region src/cp/git-credential.ts
6
6
  /** Hand out only while more than this remains (nests under the CP's 15min floor). */
7
7
  const HANDOUT_MIN_MS = 600 * 1e3;
8
+ /** Repo-level denials are retried after this — the console-authorization lag. */
9
+ const REPO_DENIAL_TTL_MS = 60 * 1e3;
8
10
  /** The widened scope set behind `GH_TOKEN` (P2.5 回写): git contents plus the
9
11
  * issue/PR scopes `gh issue comment` / `gh pr comment` need. The CP clamps
10
- * every capability to the agent's gitAccess — a read-only workspace gets a
11
- * read-only token, never a write scope. */
12
+ * every capability — the workspace repo to the agent's gitAccess, an
13
+ * additional repo to its authorization tier (comment ⇒ contents:read +
14
+ * issues/PR:write). */
12
15
  const GH_TOKEN_CAPABILITIES = [
13
16
  "contents",
14
17
  "issues",
15
18
  "pull_requests"
16
19
  ];
17
- /** Cache-key prefix separating the GH_TOKEN grant from the plain git one —
18
- * different capability sets are different tokens with separate lifecycles. */
19
- const GH_KEY = (agentId) => `gh\u0000${agentId}`;
20
+ /** Cache key — NUL-joined so no owner/repo string can collide across fields
21
+ * (the GH_KEY precedent). */
22
+ const keyOf = (agentId, plane, repo) => `${plane}\u0000${agentId}\u0000${repo?.toLowerCase() ?? ""}`;
20
23
  /** Cache key for a repo-targeted COMMENT token (the GithubPoster's — issues/PR
21
- * write on the hook-watched repo, no contents). One per (agent, repo). */
24
+ * only, no contents requested; its capability set differs from both planes so
25
+ * it gets its own keyspace). One per (agent, repo). */
22
26
  const POST_KEY = (agentId, repo) => `post\u0000${agentId}\u0000${repo.toLowerCase()}`;
23
27
  var GitCredUnavailableError = class extends Error {
24
28
  terminal;
@@ -32,50 +36,65 @@ var GitCredentialCache = class {
32
36
  deps;
33
37
  entries = /* @__PURE__ */ new Map();
34
38
  inflight = /* @__PURE__ */ new Map();
35
- /** Agents the CP told us to stop asking for (SCOPE_DENIED — moved/mode-off). */
39
+ /** Workspace keys the CP told us to stop asking for (agent-level
40
+ * SCOPE_DENIED — moved/mode-off). Per KEY, so a gh-plane denial never
41
+ * silences the git plane. */
36
42
  denied = /* @__PURE__ */ new Set();
43
+ /** Repo-keyed denials → monotonic retry-after deadline (60s negative cache). */
44
+ deniedRepos = /* @__PURE__ */ new Map();
37
45
  monoNow;
38
46
  constructor(deps) {
39
47
  this.deps = deps;
40
48
  this.monoNow = deps.monoNow ?? (() => performance.now());
41
49
  }
42
- /** A live credential for the agent's workspace repo — cached, coalesced, or freshly pulled. */
43
- async get(agentId, reason) {
44
- return this.getKeyed(agentId, agentId, {
50
+ /**
51
+ * A live credential for one of the agent's authorized repos — cached,
52
+ * coalesced, or freshly pulled. `repo` ("owner/repo") absent ⇒ the workspace
53
+ * repo (the pre-#457 behavior, byte-identical on the wire). `plane` picks the
54
+ * capability set: 'git' (contents, the credential helper) or 'gh' (the
55
+ * widened GH_TOKEN set for the gh wrapper).
56
+ */
57
+ async get(agentId, reason, opts = {}) {
58
+ const plane = opts.plane ?? "git";
59
+ return this.getKeyed(keyOf(agentId, plane, opts.repo), agentId, {
45
60
  agentId,
46
- reason
61
+ reason,
62
+ ...plane === "gh" ? { capabilities: [...GH_TOKEN_CAPABILITIES] } : {},
63
+ ...opts.repo !== void 0 ? { repoFullName: opts.repo } : {}
47
64
  });
48
65
  }
49
66
  /**
50
- * The widened GH_TOKEN grant (P2.5 回写) — same cache/coalesce/degradation
51
- * discipline as {@link get}, under its own key: a distinct capability set is
52
- * a distinct token. Injected into the agent's runtime env at host spawn;
53
- * expiry (≤1h, non-renewable) means write-back goes stale until the next
54
- * host spawn — the documented MVP limitation.
67
+ * The widened GH_TOKEN grant (P2.5 回写) for the workspace repo — kept as a
68
+ * named entry point for the spawn-time env injection. Per-invocation gh calls
69
+ * go through {@link get} with `plane: 'gh'` (and a repo) instead.
55
70
  */
56
71
  async getGhToken(agentId) {
57
- return this.getKeyed(GH_KEY(agentId), agentId, {
58
- agentId,
59
- reason: "helper",
60
- capabilities: [...GH_TOKEN_CAPABILITIES]
61
- });
72
+ return this.get(agentId, "helper", { plane: "gh" });
62
73
  }
63
74
  /**
64
- * The GithubPoster's comment token for a hook-watched repo (P3 出向): scoped
65
- * to `repo`, issues/pull_requests only — the CP refuses contents on repo-
66
- * targeted mints. Same cache/coalesce/degradation discipline as {@link get};
67
- * the token never enters the agent's environment.
75
+ * The GithubPoster's comment token (P3 出向): scoped to `repo`, requesting
76
+ * issues/pull_requests only (no contents — the token can talk on threads,
77
+ * not read code). The CP admits it through the agent's AgentRepoAuthorization
78
+ * rows like every repo-targeted mint and clamps to the row's tier (a `read`
79
+ * tier yields read-only scopes and the poster's writes 403 — authorize the
80
+ * repo at `comment` or above). Same cache/coalesce/degradation discipline as
81
+ * {@link get}; the token never enters the agent's environment.
68
82
  */
69
83
  async getPostToken(agentId, repo) {
70
84
  return this.getKeyed(POST_KEY(agentId, repo), agentId, {
71
85
  agentId,
72
86
  reason: "helper",
73
87
  capabilities: ["issues", "pull_requests"],
74
- repo
88
+ repoFullName: repo
75
89
  });
76
90
  }
77
91
  async getKeyed(key, agentId, payload) {
78
92
  if (this.denied.has(key)) throw new GitCredUnavailableError("control plane denied git credentials for this agent", true);
93
+ const retryAfter = this.deniedRepos.get(key);
94
+ if (retryAfter !== void 0) {
95
+ if (retryAfter > this.monoNow()) throw new GitCredUnavailableError(`${payload.repoFullName ?? "repo"} is not authorized for this agent — authorize it under the agent's Repositories settings`, false);
96
+ this.deniedRepos.delete(key);
97
+ }
79
98
  const cached = this.entries.get(key);
80
99
  if (cached && cached.expiresAtMono - this.monoNow() > HANDOUT_MIN_MS) return cached;
81
100
  let pending = this.inflight.get(key);
@@ -86,7 +105,7 @@ var GitCredentialCache = class {
86
105
  try {
87
106
  return await pending;
88
107
  } catch (e) {
89
- if (cached && cached.expiresAtMono > this.monoNow()) {
108
+ if (cached && this.entries.get(key) === cached && cached.expiresAtMono > this.monoNow()) {
90
109
  this.deps.log.warn(`gitcred: refresh failed for agent ${agentId} — serving cached token`);
91
110
  return cached;
92
111
  }
@@ -97,20 +116,22 @@ var GitCredentialCache = class {
97
116
  * Invalidate on rejection. Git calls the helper's `erase` with the failing
98
117
  * credential when the remote refuses it (GitHub revokes instantly on
99
118
  * uninstall/suspend) — matching on the presented password keeps a racing
100
- * fresh grant from being wiped by a stale erase.
119
+ * fresh grant from being wiped by a stale erase. `repo` routes the erase to
120
+ * the same key the `get` used; absent ⇒ the workspace git-plane entry.
101
121
  */
102
- invalidate(agentId, presentedPassword) {
103
- const entry = this.entries.get(agentId);
122
+ invalidate(agentId, presentedPassword, opts = {}) {
123
+ const key = keyOf(agentId, opts.plane ?? "git", opts.repo);
124
+ const entry = this.entries.get(key);
104
125
  if (!entry) return;
105
126
  if (presentedPassword !== void 0 && entry.token !== presentedPassword) return;
106
- this.entries.delete(agentId);
127
+ this.entries.delete(key);
107
128
  }
108
129
  /** agent/remove: drop the orphaned bearer tokens with the agent dir. */
109
130
  remove(agentId) {
110
- for (const key of [agentId, GH_KEY(agentId)]) {
111
- this.entries.delete(key);
112
- this.denied.delete(key);
113
- }
131
+ const marker = `\u0000${agentId}\u0000`;
132
+ for (const key of [...this.entries.keys()]) if (key.includes(marker)) this.entries.delete(key);
133
+ for (const key of [...this.deniedRepos.keys()]) if (key.includes(marker)) this.deniedRepos.delete(key);
134
+ for (const key of [...this.denied]) if (key.includes(marker)) this.denied.delete(key);
114
135
  }
115
136
  async fetch(key, payload) {
116
137
  let grant;
@@ -118,12 +139,18 @@ var GitCredentialCache = class {
118
139
  grant = await this.deps.request(payload);
119
140
  } catch (e) {
120
141
  if (e.code === "SCOPE_DENIED") {
142
+ if (payload.repoFullName !== void 0) {
143
+ this.deniedRepos.set(key, this.monoNow() + REPO_DENIAL_TTL_MS);
144
+ this.entries.delete(key);
145
+ throw new GitCredUnavailableError(e.message, false);
146
+ }
121
147
  this.denied.add(key);
122
148
  this.entries.delete(key);
123
149
  throw new GitCredUnavailableError(e.message, true);
124
150
  }
125
151
  throw new GitCredUnavailableError(e.message, false);
126
152
  }
153
+ if (payload.repoFullName !== void 0 && grant.repoFullName.toLowerCase() !== payload.repoFullName.toLowerCase()) throw new GitCredUnavailableError(`control plane is too old for per-repo credentials (asked ${payload.repoFullName}, got ${grant.repoFullName})`, false);
127
154
  const entry = {
128
155
  username: grant.username,
129
156
  token: grant.token,
@@ -136,31 +163,41 @@ var GitCredentialCache = class {
136
163
  }
137
164
  /** A replicated spec change may re-enable a previously denied agent. */
138
165
  clearDenied(agentId) {
139
- this.denied.delete(agentId);
140
- this.denied.delete(GH_KEY(agentId));
166
+ const marker = `\u0000${agentId}\u0000`;
167
+ for (const key of [...this.denied]) if (key.includes(marker)) this.denied.delete(key);
168
+ for (const key of [...this.deniedRepos.keys()]) if (key.includes(marker)) this.deniedRepos.delete(key);
141
169
  }
142
170
  };
143
171
  //#endregion
144
172
  //#region src/cp/gitcred-server.ts
145
173
  /**
146
- * gitcred.sock — the local credential channel for agent-run git
147
- * (docs/designs/github-app-git-credentials.md §本地 helper 通道).
174
+ * gitcred.sock — the local credential channel for agent-run git AND gh
175
+ * (docs/designs/github-app-git-credentials.md §本地 helper 通道;
176
+ * agent-multi-repo-authorization.md §daemon 侧 for per-repo routing, #457).
148
177
  *
149
178
  * A tiny newline-delimited-JSON server over a unix socket (0700 dir + 0600
150
179
  * socket, stale-socket cleanup — the `mcp/control-server.ts` pattern). The
151
- * `agentconnect git-credential` CLI subcommand connects per git invocation:
152
- * { op: 'get', agentId } → { ok, username, password } | { ok:false, error }
153
- * { op: 'erase', agentId, password? } → { ok: true }
180
+ * `agentconnect git-credential` / `gh-token` CLI subcommands connect per
181
+ * invocation:
182
+ * { op: 'get', agentId, repoFullName?, plane? } → { ok, username, password } | { ok:false, error }
183
+ * { op: 'erase', agentId, password?, repoFullName?, plane? } → { ok: true }
184
+ *
185
+ * `repoFullName` ("owner/repo") routes to that repo's token; absent — or equal
186
+ * to the agent's workspace repo, which is NORMALIZED onto the repo-less key so
187
+ * the helper path and the pre-warm/spawn paths share one cache entry — ⇒ the
188
+ * workspace token. `plane: 'gh'` picks the widened GH_TOKEN capability set.
154
189
  *
155
190
  * Trust model (design §信任模型 #1): same host + same OS user = ONE trust
156
- * domain. `agentId` here is routing, not authorization — any same-user process
157
- * can already read every workspace on this daemon. Tokens transit the socket
158
- * and helper stdout only; nothing lands on disk.
191
+ * domain. `agentId`/`repoFullName` here are routing, not authorization (the CP
192
+ * gate decides) — any same-user process can already read every workspace on
193
+ * this daemon. Tokens transit the socket and helper stdout only; nothing lands
194
+ * on disk.
159
195
  *
160
- * Alongside the socket the daemon (re)writes two SECRET-FREE files per boot:
196
+ * Alongside the socket the daemon (re)writes SECRET-FREE files per boot:
161
197
  * the shim `run/git-credential-helper.sh` (pins the current node + CLI path so
162
- * `.git/config` survives daemon upgrades) and per-agent gitconfig includes for
163
- * the session-env channel (see workspace/git-injection.ts).
198
+ * `.git/config` survives daemon upgrades), the `run/bin/gh` wrapper (see
199
+ * cp/gh-shim.ts) and per-agent gitconfig includes for the session-env channel
200
+ * (see workspace/git-injection.ts).
164
201
  */
165
202
  function gitcredSocketPath(root) {
166
203
  return join(root, "run", "gitcred.sock");
@@ -171,12 +208,14 @@ function gitcredShimPath(root) {
171
208
  var GitCredServer = class {
172
209
  cache;
173
210
  path;
174
- log;
175
211
  server;
176
- constructor(cache, path, log) {
212
+ log;
213
+ workspaceRepoOf;
214
+ constructor(cache, path, deps) {
177
215
  this.cache = cache;
178
216
  this.path = path;
179
- this.log = log;
217
+ this.log = deps.log;
218
+ if (deps.workspaceRepoOf) this.workspaceRepoOf = deps.workspaceRepoOf;
180
219
  }
181
220
  start() {
182
221
  const dir = dirname(this.path);
@@ -228,8 +267,17 @@ var GitCredServer = class {
228
267
  error: "malformed request"
229
268
  });
230
269
  }
270
+ const plane = req.plane === "gh" ? "gh" : "git";
271
+ let repo = typeof req.repoFullName === "string" && req.repoFullName.includes("/") ? req.repoFullName : void 0;
272
+ if (repo !== void 0) {
273
+ const workspace = this.workspaceRepoOf?.(req.agentId);
274
+ if (workspace && workspace.toLowerCase() === repo.toLowerCase()) repo = void 0;
275
+ }
231
276
  if (req.op === "erase") {
232
- this.cache.invalidate(req.agentId, req.password);
277
+ this.cache.invalidate(req.agentId, req.password, {
278
+ plane,
279
+ ...repo !== void 0 ? { repo } : {}
280
+ });
233
281
  return reply({ ok: true });
234
282
  }
235
283
  if (req.op !== "get" || typeof req.agentId !== "string") return reply({
@@ -237,11 +285,15 @@ var GitCredServer = class {
237
285
  error: "unsupported op"
238
286
  });
239
287
  try {
240
- const cred = await this.cache.get(req.agentId, "helper");
288
+ const cred = await this.cache.get(req.agentId, "helper", {
289
+ plane,
290
+ ...repo !== void 0 ? { repo } : {}
291
+ });
241
292
  return reply({
242
293
  ok: true,
243
294
  username: cred.username,
244
- password: cred.token
295
+ password: cred.token,
296
+ repoFullName: cred.repoFullName
245
297
  });
246
298
  } catch (e) {
247
299
  return reply({
@@ -282,4 +334,4 @@ function writeGitcredShim(root, cliEntry) {
282
334
  //#endregion
283
335
  export { GitCredentialCache as i, gitcredSocketPath as n, writeGitcredShim as r, GitCredServer as t };
284
336
 
285
- //# sourceMappingURL=gitcred-server-Dhp-tyEI.js.map
337
+ //# sourceMappingURL=gitcred-server-KXxLyYxc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gitcred-server-KXxLyYxc.js","names":[],"sources":["../src/cp/git-credential.ts","../src/cp/gitcred-server.ts"],"sourcesContent":["/**\n * GitCredentialCache — daemon-side memory for CP-minted git credentials\n * (docs/designs/github-app-git-credentials.md §daemon 侧;\n * docs/designs/agent-multi-repo-authorization.md §daemon 侧 for the\n * per-repo keying, issue #457).\n *\n * Tokens live ONLY here (and transiently on helper stdout pipes): never disk,\n * never argv, never a log line. Expiry is tracked on the MONOTONIC clock as\n * `receivedAt + ttlSec` — a skewed wall clock must never resurrect a dead\n * token (`expiresAt` in the grant is observability only).\n *\n * Keying: `(agentId, plane, repo?)` — the git plane ('contents' capability)\n * and the gh plane (contents+issues+pull_requests) are distinct tokens with\n * separate lifecycles, and every non-workspace repo is its own token (single\n * repo scope). `repo` absent ⇒ the agent's workspace repo, the pre-#457 keys.\n *\n * Threshold nesting: entries are handed out only while >10min remain (the CP\n * serves grants with ≥15min, so a fresh grant always clears this). Per-key\n * in-flight coalescing (the `cloneInFlight` pattern) keeps a cold cache from\n * fanning N concurrent helper/pull/prefetch calls into N WS REQs.\n *\n * Denial semantics split (multi-repo design §安全边界): an AGENT-level\n * SCOPE_DENIED (workspace request) is terminal — stop asking until the spec\n * replicates. A REPO-level SCOPE_DENIED (request that named a repo) is a\n * 60s NEGATIVE CACHE only: the operator authorizing the repo in the console\n * must take effect on the next gh/git call within a minute, without a spec\n * push or daemon restart.\n *\n * Degradation: outside READY the fetch fails fast (see CpClient.requestGitCred)\n * and an UNEXPIRED cached token keeps serving — CP downtime only breaks remote\n * git once the hour runs out.\n */\nimport type { GitCredCapability, GitCredGrant } from '@agentconnect.md/protocol'\n\n/** Hand out only while more than this remains (nests under the CP's 15min floor). */\nconst HANDOUT_MIN_MS = 10 * 60 * 1000\n\n/** Repo-level denials are retried after this — the console-authorization lag. */\nconst REPO_DENIAL_TTL_MS = 60 * 1000\n\n/** The widened scope set behind `GH_TOKEN` (P2.5 回写): git contents plus the\n * issue/PR scopes `gh issue comment` / `gh pr comment` need. The CP clamps\n * every capability — the workspace repo to the agent's gitAccess, an\n * additional repo to its authorization tier (comment ⇒ contents:read +\n * issues/PR:write). */\nconst GH_TOKEN_CAPABILITIES: readonly GitCredCapability[] = ['contents', 'issues', 'pull_requests']\n\n/** The credential plane: 'git' = contents-only (the credential helper), 'gh' =\n * the widened GH_TOKEN set. Distinct capability sets are distinct tokens. */\nexport type CredPlane = 'git' | 'gh'\n\n/** Cache key — NUL-joined so no owner/repo string can collide across fields\n * (the GH_KEY precedent). */\nconst keyOf = (agentId: string, plane: CredPlane, repo?: string): string =>\n `${plane}\\u0000${agentId}\\u0000${repo?.toLowerCase() ?? ''}`\n\n/** Cache key for a repo-targeted COMMENT token (the GithubPoster's — issues/PR\n * only, no contents requested; its capability set differs from both planes so\n * it gets its own keyspace). One per (agent, repo). */\nconst POST_KEY = (agentId: string, repo: string): string => `post\\u0000${agentId}\\u0000${repo.toLowerCase()}`\n\nexport class GitCredUnavailableError extends Error {\n constructor(\n message: string,\n /** Terminal (agent-level SCOPE_DENIED): stop asking for this agent until config changes. */\n readonly terminal: boolean\n ) {\n super(message)\n this.name = 'GitCredUnavailableError'\n }\n}\n\ninterface Entry {\n username: string\n token: string\n repoFullName: string\n access: 'read' | 'write'\n /** Monotonic deadline (ms on the injected monotonic clock). */\n expiresAtMono: number\n}\n\nexport interface GitCredentialCacheDeps {\n /** D→C `gitcred/request` — throws WireError-shaped errors (`code`, `retryable`). */\n request: (payload: {\n agentId: string\n reason?: 'clone' | 'fetch' | 'pull' | 'push' | 'helper'\n capabilities?: GitCredCapability[]\n repoFullName?: string\n }) => Promise<GitCredGrant>\n log: { warn: (msg: string) => void }\n /** Monotonic ms; injectable for tests. */\n monoNow?: () => number\n}\n\nexport class GitCredentialCache {\n private readonly entries = new Map<string, Entry>()\n private readonly inflight = new Map<string, Promise<Entry>>()\n /** Workspace keys the CP told us to stop asking for (agent-level\n * SCOPE_DENIED — moved/mode-off). Per KEY, so a gh-plane denial never\n * silences the git plane. */\n private readonly denied = new Set<string>()\n /** Repo-keyed denials → monotonic retry-after deadline (60s negative cache). */\n private readonly deniedRepos = new Map<string, number>()\n private readonly monoNow: () => number\n\n constructor(private readonly deps: GitCredentialCacheDeps) {\n this.monoNow = deps.monoNow ?? (() => performance.now())\n }\n\n /**\n * A live credential for one of the agent's authorized repos — cached,\n * coalesced, or freshly pulled. `repo` (\"owner/repo\") absent ⇒ the workspace\n * repo (the pre-#457 behavior, byte-identical on the wire). `plane` picks the\n * capability set: 'git' (contents, the credential helper) or 'gh' (the\n * widened GH_TOKEN set for the gh wrapper).\n */\n async get(\n agentId: string,\n reason: 'clone' | 'fetch' | 'pull' | 'push' | 'helper',\n opts: { plane?: CredPlane; repo?: string } = {}\n ): Promise<Entry> {\n const plane = opts.plane ?? 'git'\n return this.getKeyed(keyOf(agentId, plane, opts.repo), agentId, {\n agentId,\n reason,\n ...(plane === 'gh' ? { capabilities: [...GH_TOKEN_CAPABILITIES] } : {}),\n ...(opts.repo !== undefined ? { repoFullName: opts.repo } : {})\n })\n }\n\n /**\n * The widened GH_TOKEN grant (P2.5 回写) for the workspace repo — kept as a\n * named entry point for the spawn-time env injection. Per-invocation gh calls\n * go through {@link get} with `plane: 'gh'` (and a repo) instead.\n */\n async getGhToken(agentId: string): Promise<Entry> {\n return this.get(agentId, 'helper', { plane: 'gh' })\n }\n\n /**\n * The GithubPoster's comment token (P3 出向): scoped to `repo`, requesting\n * issues/pull_requests only (no contents — the token can talk on threads,\n * not read code). The CP admits it through the agent's AgentRepoAuthorization\n * rows like every repo-targeted mint and clamps to the row's tier (a `read`\n * tier yields read-only scopes and the poster's writes 403 — authorize the\n * repo at `comment` or above). Same cache/coalesce/degradation discipline as\n * {@link get}; the token never enters the agent's environment.\n */\n async getPostToken(agentId: string, repo: string): Promise<Entry> {\n return this.getKeyed(POST_KEY(agentId, repo), agentId, {\n agentId,\n reason: 'helper',\n capabilities: ['issues', 'pull_requests'],\n repoFullName: repo\n })\n }\n\n private async getKeyed(\n key: string,\n agentId: string,\n payload: Parameters<GitCredentialCacheDeps['request']>[0]\n ): Promise<Entry> {\n if (this.denied.has(key)) {\n throw new GitCredUnavailableError('control plane denied git credentials for this agent', true)\n }\n const retryAfter = this.deniedRepos.get(key)\n if (retryAfter !== undefined) {\n if (retryAfter > this.monoNow()) {\n throw new GitCredUnavailableError(\n `${payload.repoFullName ?? 'repo'} is not authorized for this agent — authorize it under the agent's Repositories settings`,\n false\n )\n }\n this.deniedRepos.delete(key)\n }\n const cached = this.entries.get(key)\n if (cached && cached.expiresAtMono - this.monoNow() > HANDOUT_MIN_MS) return cached\n\n let pending = this.inflight.get(key)\n if (!pending) {\n pending = this.fetch(key, payload).finally(() => this.inflight.delete(key))\n this.inflight.set(key, pending)\n }\n try {\n return await pending\n } catch (e) {\n // CP unreachable / transient: an UNEXPIRED cached token still serves.\n // A DENIAL is authoritative, not transient — fetch() deletes the entry on\n // SCOPE_DENIED, so the identity check (`=== cached`) fails and we do NOT\n // resurrect the token the denial just purged (a revoked repo must break on\n // the discovering call, not one call later).\n if (cached && this.entries.get(key) === cached && cached.expiresAtMono > this.monoNow()) {\n this.deps.log.warn(`gitcred: refresh failed for agent ${agentId} — serving cached token`)\n return cached\n }\n throw e\n }\n }\n\n /**\n * Invalidate on rejection. Git calls the helper's `erase` with the failing\n * credential when the remote refuses it (GitHub revokes instantly on\n * uninstall/suspend) — matching on the presented password keeps a racing\n * fresh grant from being wiped by a stale erase. `repo` routes the erase to\n * the same key the `get` used; absent ⇒ the workspace git-plane entry.\n */\n invalidate(agentId: string, presentedPassword?: string, opts: { plane?: CredPlane; repo?: string } = {}): void {\n const key = keyOf(agentId, opts.plane ?? 'git', opts.repo)\n const entry = this.entries.get(key)\n if (!entry) return\n if (presentedPassword !== undefined && entry.token !== presentedPassword) return\n this.entries.delete(key)\n }\n\n /** agent/remove: drop the orphaned bearer tokens with the agent dir. */\n remove(agentId: string): void {\n const marker = `\\u0000${agentId}\\u0000`\n for (const key of [...this.entries.keys()]) if (key.includes(marker)) this.entries.delete(key)\n for (const key of [...this.deniedRepos.keys()]) if (key.includes(marker)) this.deniedRepos.delete(key)\n for (const key of [...this.denied]) if (key.includes(marker)) this.denied.delete(key)\n }\n\n private async fetch(key: string, payload: Parameters<GitCredentialCacheDeps['request']>[0]): Promise<Entry> {\n let grant: GitCredGrant\n try {\n grant = await this.deps.request(payload)\n } catch (e) {\n const code = (e as { code?: string }).code\n if (code === 'SCOPE_DENIED') {\n if (payload.repoFullName !== undefined) {\n // Repo-keyed: negative-cache and retry in a minute — the operator may\n // be authorizing the repo in the console right now.\n this.deniedRepos.set(key, this.monoNow() + REPO_DENIAL_TTL_MS)\n this.entries.delete(key)\n throw new GitCredUnavailableError((e as Error).message, false)\n }\n // Workspace-keyed terminal: the agent moved daemons or left github-app\n // mode. Stop asking on THIS key until the CP replicates a new spec\n // (which clears it).\n this.denied.add(key)\n this.entries.delete(key)\n throw new GitCredUnavailableError((e as Error).message, true)\n }\n throw new GitCredUnavailableError((e as Error).message, false)\n }\n // Old-CP mismatch guard (multi-repo design §协议改动): a CP that predates\n // `repoFullName` strips the field and answers with the WORKSPACE grant —\n // serving that under a repo key would hand the wrong repo's token to gh/git.\n // A NEW CP ECHOES the requested name back verbatim (even after a GitHub\n // canonical rename — it mints by numeric id but reports what we asked), so\n // this only ever fires on the genuine old-CP case, never on a rename.\n if (payload.repoFullName !== undefined && grant.repoFullName.toLowerCase() !== payload.repoFullName.toLowerCase()) {\n throw new GitCredUnavailableError(\n `control plane is too old for per-repo credentials (asked ${payload.repoFullName}, got ${grant.repoFullName})`,\n false\n )\n }\n const entry: Entry = {\n username: grant.username,\n token: grant.token,\n repoFullName: grant.repoFullName,\n access: grant.access,\n expiresAtMono: this.monoNow() + grant.ttlSec * 1000\n }\n this.entries.set(key, entry)\n return entry\n }\n\n /** A replicated spec change may re-enable a previously denied agent. */\n clearDenied(agentId: string): void {\n const marker = `\\u0000${agentId}\\u0000`\n for (const key of [...this.denied]) if (key.includes(marker)) this.denied.delete(key)\n for (const key of [...this.deniedRepos.keys()]) if (key.includes(marker)) this.deniedRepos.delete(key)\n }\n}\n","/**\n * gitcred.sock — the local credential channel for agent-run git AND gh\n * (docs/designs/github-app-git-credentials.md §本地 helper 通道;\n * agent-multi-repo-authorization.md §daemon 侧 for per-repo routing, #457).\n *\n * A tiny newline-delimited-JSON server over a unix socket (0700 dir + 0600\n * socket, stale-socket cleanup — the `mcp/control-server.ts` pattern). The\n * `agentconnect git-credential` / `gh-token` CLI subcommands connect per\n * invocation:\n * { op: 'get', agentId, repoFullName?, plane? } → { ok, username, password } | { ok:false, error }\n * { op: 'erase', agentId, password?, repoFullName?, plane? } → { ok: true }\n *\n * `repoFullName` (\"owner/repo\") routes to that repo's token; absent — or equal\n * to the agent's workspace repo, which is NORMALIZED onto the repo-less key so\n * the helper path and the pre-warm/spawn paths share one cache entry — ⇒ the\n * workspace token. `plane: 'gh'` picks the widened GH_TOKEN capability set.\n *\n * Trust model (design §信任模型 #1): same host + same OS user = ONE trust\n * domain. `agentId`/`repoFullName` here are routing, not authorization (the CP\n * gate decides) — any same-user process can already read every workspace on\n * this daemon. Tokens transit the socket and helper stdout only; nothing lands\n * on disk.\n *\n * Alongside the socket the daemon (re)writes SECRET-FREE files per boot:\n * the shim `run/git-credential-helper.sh` (pins the current node + CLI path so\n * `.git/config` survives daemon upgrades), the `run/bin/gh` wrapper (see\n * cp/gh-shim.ts) and per-agent gitconfig includes for the session-env channel\n * (see workspace/git-injection.ts).\n */\nimport { createServer, type Server, type Socket } from 'node:net'\nimport { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'\nimport { createRequire } from 'node:module'\nimport { dirname, join } from 'node:path'\nimport { GitCredentialCache, GitCredUnavailableError, type CredPlane } from './git-credential.js'\n\nexport function gitcredSocketPath(root: string): string {\n return join(root, 'run', 'gitcred.sock')\n}\n\nexport function gitcredShimPath(root: string): string {\n return join(root, 'run', 'git-credential-helper.sh')\n}\n\ninterface GitCredIpcRequest {\n op: 'get' | 'erase'\n agentId: string\n password?: string\n /** \"owner/repo\" to route to (multi-repo, #457); absent ⇒ workspace. */\n repoFullName?: string\n /** 'gh' ⇒ the widened GH_TOKEN capability set; absent/'git' ⇒ contents-only. */\n plane?: string\n}\n\nexport interface GitCredServerDeps {\n log: { info: (m: string) => void; warn: (m: string) => void }\n /** The agent's workspace \"owner/repo\" label (lowercase-insensitive compare) —\n * lets a helper request that names the workspace repo share the repo-less\n * cache key with pre-warm/spawn instead of splitting the cache. */\n workspaceRepoOf?: (agentId: string) => string | undefined\n}\n\nexport class GitCredServer {\n private server?: Server\n private readonly log: GitCredServerDeps['log']\n private readonly workspaceRepoOf?: (agentId: string) => string | undefined\n\n constructor(\n private readonly cache: GitCredentialCache,\n private readonly path: string,\n deps: GitCredServerDeps\n ) {\n this.log = deps.log\n if (deps.workspaceRepoOf) this.workspaceRepoOf = deps.workspaceRepoOf\n }\n\n start(): void {\n const dir = dirname(this.path)\n mkdirSync(dir, { recursive: true, mode: 0o700 })\n try {\n chmodSync(dir, 0o700) // defeat a loose umask; best-effort on non-POSIX\n } catch {\n /* best-effort */\n }\n rmSync(this.path, { force: true }) // a stale socket from a crash makes listen() throw\n\n const server = createServer((sock) => this.serve(sock))\n server.listen(this.path, () => {\n try {\n chmodSync(this.path, 0o600)\n } catch {\n /* best-effort */\n }\n this.log.info(`gitcred: helper socket at ${this.path}`)\n })\n server.on('error', (e) => this.log.warn(`gitcred: socket error: ${e.message}`))\n this.server = server\n }\n\n stop(): void {\n this.server?.close()\n rmSync(this.path, { force: true })\n }\n\n private serve(sock: Socket): void {\n let buf = ''\n sock.on('data', (chunk) => {\n buf += chunk.toString('utf8')\n const nl = buf.indexOf('\\n')\n if (nl === -1) return\n const line = buf.slice(0, nl)\n buf = buf.slice(nl + 1)\n void this.handle(line, sock)\n })\n sock.on('error', () => sock.destroy())\n }\n\n private async handle(line: string, sock: Socket): Promise<void> {\n const reply = (msg: unknown) => {\n sock.write(JSON.stringify(msg) + '\\n')\n sock.end()\n }\n let req: GitCredIpcRequest\n try {\n req = JSON.parse(line) as GitCredIpcRequest\n } catch {\n return reply({ ok: false, error: 'malformed request' })\n }\n const plane: CredPlane = req.plane === 'gh' ? 'gh' : 'git'\n // Workspace normalization: a request naming the workspace repo folds onto\n // the repo-less key (one cache entry with pre-warm/spawn; and old CPs that\n // strip the wire field keep serving the workspace unchanged).\n let repo = typeof req.repoFullName === 'string' && req.repoFullName.includes('/') ? req.repoFullName : undefined\n if (repo !== undefined) {\n const workspace = this.workspaceRepoOf?.(req.agentId)\n if (workspace && workspace.toLowerCase() === repo.toLowerCase()) repo = undefined\n }\n if (req.op === 'erase') {\n // Git presents the rejected credential — GitHub revokes instantly on\n // uninstall/suspend, and this is how the daemon cache learns.\n this.cache.invalidate(req.agentId, req.password, { plane, ...(repo !== undefined ? { repo } : {}) })\n return reply({ ok: true })\n }\n if (req.op !== 'get' || typeof req.agentId !== 'string') {\n return reply({ ok: false, error: 'unsupported op' })\n }\n try {\n const cred = await this.cache.get(req.agentId, 'helper', { plane, ...(repo !== undefined ? { repo } : {}) })\n return reply({ ok: true, username: cred.username, password: cred.token, repoFullName: cred.repoFullName })\n } catch (e) {\n const msg =\n e instanceof GitCredUnavailableError ? e.message : `git credentials unavailable: ${(e as Error).message}`\n return reply({ ok: false, error: msg })\n }\n }\n}\n\n/**\n * (Re)write the secret-free shim `.git/config` helper lines exec through. The\n * absolute node + CLI paths are re-pinned every daemon boot, so repo configs\n * keep working across upgrades/relocations. Quoted throughout — a home dir\n * with a space (macOS \"/Users/John Smith/…\") must not word-split.\n */\nexport function writeGitcredShim(root: string, cliEntry: string): string {\n const shim = gitcredShimPath(root)\n mkdirSync(dirname(shim), { recursive: true, mode: 0o700 })\n const q = (v: string) => `'${v.replaceAll(\"'\", \"'\\\\''\")}'`\n // Production runs the built dist (a .js entry node executes directly). A dev\n // daemon runs under tsx with a .ts argv[1] — route the shim through the tsx\n // CLI then, or plain `node entry.ts` would die resolving .js-suffixed imports.\n const argv = [q(process.execPath)]\n if (cliEntry.endsWith('.ts')) {\n const req = createRequire(import.meta.url)\n argv.push(q(req.resolve('tsx/cli')))\n }\n argv.push(q(cliEntry))\n const body = [\n '#!/bin/sh',\n '# agentconnect git credential helper shim — regenerated on daemon start; NO secrets.',\n `AGENTCONNECT_ROOT=${q(root)} \\\\`,\n ` exec ${argv.join(' ')} git-credential \"$@\"`,\n ''\n ].join('\\n')\n writeFileSync(shim, body, { mode: 0o755 })\n return shim\n}\n"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -68492,9 +68492,9 @@ const telemetry = startDaemonOpenTelemetry({ serviceVersion: DAEMON_VERSION });
68492
68492
  const [{ Command }, { fileURLToPath }, { runChat }, { runLogin }, { runForeground }, { resolveController }, { acquireSingletonLock }, { resolveRoot }] = await Promise.all([
68493
68493
  import("./commander-B5QVEFzH.js"),
68494
68494
  import("node:url"),
68495
- import("./chat-IRJd4_lj.js"),
68496
- import("./login-xtaBgstv.js"),
68497
- import("./run-foreground-BiYUl931.js"),
68495
+ import("./chat-D8QIIHor.js"),
68496
+ import("./login-BP4bFP-d.js"),
68497
+ import("./run-foreground-jOExu3YR.js"),
68498
68498
  import("./service-6w0WKhdx.js"),
68499
68499
  import("./lock-CA9Bm8_s.js"),
68500
68500
  import("./paths-CcRUjFjy.js")
@@ -68646,13 +68646,17 @@ program.command("login").description("Interactive onboarding: test the Control P
68646
68646
  }
68647
68647
  });
68648
68648
  program.command("git-credential").description("Git credential helper backed by the local daemon (github-app workspaces)").argument("<agentId>", "agent whose workspace credentials to serve").argument("<action>", "git credential action: get | store | erase").action(async (agentId, action) => {
68649
- const { runGitCredential } = await import("./git-credential-DLRF9bOx.js");
68649
+ const { runGitCredential } = await import("./git-credential-DhWn1IBJ.js");
68650
68650
  await runGitCredential(action, agentId);
68651
68651
  });
68652
+ program.command("gh-token").description("Fetch a per-repo GH_TOKEN from the local daemon (gh wrapper backend)").argument("<agentId>", "agent whose credentials to serve").argument("[repo]", "target repo (owner/repo, host/owner/repo, or URL); absent = workspace").action(async (agentId, repo) => {
68653
+ const { runGhToken } = await import("./gh-token-BfPlYQav.js");
68654
+ await runGhToken(agentId, repo);
68655
+ });
68652
68656
  program.command("agent").description("Inspect local agent directories").command("list").description("List agents discovered under --agents-dir (id, status, runtime, name, dir)").action(async () => {
68653
68657
  const opts = program.opts();
68654
68658
  try {
68655
- const { runAgentList } = await import("./agent-list-DHFLd9ux.js");
68659
+ const { runAgentList } = await import("./agent-list-Da9Osn4O.js");
68656
68660
  await runAgentList({
68657
68661
  agentsDir: opts.agentsDir,
68658
68662
  configPath: opts.config,