@yagni-app/code 1.0.8 → 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/README.md +39 -3
- package/dist/cli.js +1 -1
- package/dist/connectClaudeCode.d.ts +4 -3
- package/dist/connectClaudeCode.js +70 -37
- package/dist/connectCodex.d.ts +6 -2
- package/dist/connectCodex.js +43 -22
- package/dist/connectFiles.d.ts +5 -0
- package/dist/connectFiles.js +49 -0
- package/dist/extension/hooks.d.ts +13 -3
- package/dist/extension/hooks.js +170 -5
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +2 -0
- package/dist/extension/mcp/config.js +2 -0
- package/dist/extension/mcp/tools.js +69 -6
- package/dist/extension/pipeline/scrubSecrets.js +5 -0
- package/dist/extension/resilientFetch.d.ts +3 -3
- package/dist/extension/resilientFetch.js +7 -4
- package/dist/extension/sandbox/config.d.ts +6 -1
- package/dist/extension/sandbox/config.js +19 -1
- package/dist/extension/sandbox/manager.d.ts +9 -2
- package/dist/extension/sandbox/manager.js +30 -5
- package/dist/extension/sandbox/panel.js +2 -1
- package/dist/extension/sandbox/session.js +7 -0
- package/dist/extension/sandbox/worktreeGit.d.ts +57 -0
- package/dist/extension/sandbox/worktreeGit.js +157 -0
- package/dist/extension/telemetry/attrs.d.ts +7 -0
- package/dist/extension/telemetry/attrs.js +7 -0
- package/dist/extension/telemetry/tracker.js +8 -1
- package/dist/mcpCommand.d.ts +7 -0
- package/dist/mcpCommand.js +161 -18
- package/dist/refresh.js +5 -2
- package/dist/token.d.ts +2 -1
- package/dist/token.js +29 -3
- package/package.json +2 -2
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { Box, Container, HStack, Key, SelectList, Text, matchesKey, } from "@earendil-works/pi-tui";
|
|
22
22
|
import { mergeRulesIntoSandbox } from "./config.js";
|
|
23
|
+
import { resolveWorktreeGitAccess } from "./worktreeGit.js";
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Pure derivation helpers (unit-tested; no TUI dependency)
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
@@ -335,7 +336,7 @@ export function buildPanelState(settings, sessionToggledOff, rules, paths, sessi
|
|
|
335
336
|
sessionToggledOff,
|
|
336
337
|
dependencyErrors: dependencyStatus.errors,
|
|
337
338
|
dependencyWarnings: dependencyStatus.warnings,
|
|
338
|
-
merge: mergeRulesIntoSandbox(settings, rules, paths),
|
|
339
|
+
merge: mergeRulesIntoSandbox(settings, rules, paths, resolveWorktreeGitAccess(paths.cwd)),
|
|
339
340
|
sessionGrants: [...sessionGrants],
|
|
340
341
|
};
|
|
341
342
|
}
|
|
@@ -22,6 +22,7 @@ import { codeStateHome } from "../stateHome.js";
|
|
|
22
22
|
import { isDebug } from "../diagnostics.js";
|
|
23
23
|
import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
|
|
24
24
|
import { loadSandboxSettings } from "./config.js";
|
|
25
|
+
import { resolveWorktreeGitAccess } from "./worktreeGit.js";
|
|
25
26
|
import { annotateCommandOutput, makeSandboxSpawnHook, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
|
|
26
27
|
import { YagniSandboxManager } from "./manager.js";
|
|
27
28
|
import { SandboxPanel, buildPanelState } from "./panel.js";
|
|
@@ -68,6 +69,12 @@ export function makeBashComposition(manager, settings, cwd) {
|
|
|
68
69
|
? `Network: only these domains (wildcards ok): ${allowed.join(", ")}`
|
|
69
70
|
: "Network: no domains pre-allowed — the first contact to each host prompts the user");
|
|
70
71
|
restrictions.push(`Filesystem writes: working directory, /tmp, and paths granted by Edit(...) allow rules`);
|
|
72
|
+
// A linked-worktree session additionally allows the shared common git
|
|
73
|
+
// dir (git operations work sandboxed there); the description must say
|
|
74
|
+
// so or the model under-reports what it can do.
|
|
75
|
+
if (resolveWorktreeGitAccess(cwd)) {
|
|
76
|
+
restrictions.push("Filesystem writes (git): the linked worktree's shared .git directory — git operations (add/commit/branch/checkout) are allowed and should run sandboxed; its hooks/ and config remain read-only");
|
|
77
|
+
}
|
|
71
78
|
if (s.filesystem?.denyWrite?.length)
|
|
72
79
|
restrictions.push(`Denied writes: ${s.filesystem.denyWrite.join(", ")}`);
|
|
73
80
|
const strictNote = s.allowUnsandboxedCommands === false
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-worktree git resolution for the sandbox. When the session cwd is
|
|
3
|
+
* the root of a linked worktree, routine git writes (status/add/commit/
|
|
4
|
+
* branch/checkout) land in the MAIN repo's shared `.git` directory —
|
|
5
|
+
* outside the sandbox's default write allowlist ({cwd, tmpdir()}), so every
|
|
6
|
+
* one of them fails with EPERM and forces the dangerouslyDisableSandbox
|
|
7
|
+
* retry path. Resolving the shared common git dir here lets the sandbox
|
|
8
|
+
* allow exactly that directory (never the main checkout's working tree).
|
|
9
|
+
*
|
|
10
|
+
* Resolution chain (Claude Code's resolveCanonicalRoot, src/utils/git.ts,
|
|
11
|
+
* applied to the sandbox problem — Claude's own sandbox side only does a
|
|
12
|
+
* shape check on the gitdir path, which would trust an attacker-crafted
|
|
13
|
+
* `.git` file; the full chain here is deliberately stricter):
|
|
14
|
+
* cwd/.git file → `gitdir:` → resolve against cwd → <gitdir>/commondir →
|
|
15
|
+
* resolve against gitdir → common git dir. Both validation checks from
|
|
16
|
+
* the reference are enforced before anything is returned:
|
|
17
|
+
* 1. structural — the worktree gitdir is a direct child of
|
|
18
|
+
* <commonDir>/worktrees/ (the commondir we read lives where git put it,
|
|
19
|
+
* not wherever an attacker's `.git` file pointed);
|
|
20
|
+
* 2. back-link — <gitdir>/gitdir points back at THIS cwd's .git (an
|
|
21
|
+
* attacker cannot borrow an existing worktree entry of another repo).
|
|
22
|
+
*
|
|
23
|
+
* Fail-through cases (all return null, no behavior change vs the
|
|
24
|
+
* pre-worktree-allow sandbox):
|
|
25
|
+
* plain main checkout (.git is a directory → EISDIR), no repo at all,
|
|
26
|
+
* submodule (.git file but no commondir), failed structural/back-link
|
|
27
|
+
* validation, any read/parse error.
|
|
28
|
+
*
|
|
29
|
+
* Bare-repo worktrees are supported: there the common dir is the bare repo
|
|
30
|
+
* itself (`<bare>/worktrees/<name>/commondir` → `<bare>`), so the allowed
|
|
31
|
+
* directory IS the common dir (Claude's `/.git/worktrees/` shape marker
|
|
32
|
+
* silently no-ops there; ours resolves it).
|
|
33
|
+
*
|
|
34
|
+
* Root-only by design (Claude sandbox-side parity): a session started in a
|
|
35
|
+
* worktree SUBDIRECTORY keeps the pre-existing behavior — the `.git` file
|
|
36
|
+
* sits only at the worktree root.
|
|
37
|
+
*/
|
|
38
|
+
/** Resolved write-target facts for a linked-worktree session. */
|
|
39
|
+
export interface WorktreeGitAccess {
|
|
40
|
+
/** The shared common git dir git writes actually land in. */
|
|
41
|
+
commonGitDir: string;
|
|
42
|
+
/** Realpath'd worktree root (safe.directory needs the on-disk spelling). */
|
|
43
|
+
worktreeRoot: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the shared common git dir when cwd is a linked worktree root.
|
|
47
|
+
* Returns null for every non-worktree / untrusted shape — callers treat
|
|
48
|
+
* null as "no extra entries" and never widen the sandbox.
|
|
49
|
+
*
|
|
50
|
+
* Null REASONS are surfaced (debug level, paths only) whenever a `gitdir:`
|
|
51
|
+
* .git file existed but validation refused: a legitimate worktree that
|
|
52
|
+
* silently misses the allow is indistinguishable from "not a worktree"
|
|
53
|
+
* without them. The plain-repo / no-repo fall-throughs stay silent — they
|
|
54
|
+
* are the common case and carry no signal.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveWorktreeGitAccess(cwd: string): WorktreeGitAccess | null;
|
|
57
|
+
//# sourceMappingURL=worktreeGit.d.ts.map
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-worktree git resolution for the sandbox. When the session cwd is
|
|
3
|
+
* the root of a linked worktree, routine git writes (status/add/commit/
|
|
4
|
+
* branch/checkout) land in the MAIN repo's shared `.git` directory —
|
|
5
|
+
* outside the sandbox's default write allowlist ({cwd, tmpdir()}), so every
|
|
6
|
+
* one of them fails with EPERM and forces the dangerouslyDisableSandbox
|
|
7
|
+
* retry path. Resolving the shared common git dir here lets the sandbox
|
|
8
|
+
* allow exactly that directory (never the main checkout's working tree).
|
|
9
|
+
*
|
|
10
|
+
* Resolution chain (Claude Code's resolveCanonicalRoot, src/utils/git.ts,
|
|
11
|
+
* applied to the sandbox problem — Claude's own sandbox side only does a
|
|
12
|
+
* shape check on the gitdir path, which would trust an attacker-crafted
|
|
13
|
+
* `.git` file; the full chain here is deliberately stricter):
|
|
14
|
+
* cwd/.git file → `gitdir:` → resolve against cwd → <gitdir>/commondir →
|
|
15
|
+
* resolve against gitdir → common git dir. Both validation checks from
|
|
16
|
+
* the reference are enforced before anything is returned:
|
|
17
|
+
* 1. structural — the worktree gitdir is a direct child of
|
|
18
|
+
* <commonDir>/worktrees/ (the commondir we read lives where git put it,
|
|
19
|
+
* not wherever an attacker's `.git` file pointed);
|
|
20
|
+
* 2. back-link — <gitdir>/gitdir points back at THIS cwd's .git (an
|
|
21
|
+
* attacker cannot borrow an existing worktree entry of another repo).
|
|
22
|
+
*
|
|
23
|
+
* Fail-through cases (all return null, no behavior change vs the
|
|
24
|
+
* pre-worktree-allow sandbox):
|
|
25
|
+
* plain main checkout (.git is a directory → EISDIR), no repo at all,
|
|
26
|
+
* submodule (.git file but no commondir), failed structural/back-link
|
|
27
|
+
* validation, any read/parse error.
|
|
28
|
+
*
|
|
29
|
+
* Bare-repo worktrees are supported: there the common dir is the bare repo
|
|
30
|
+
* itself (`<bare>/worktrees/<name>/commondir` → `<bare>`), so the allowed
|
|
31
|
+
* directory IS the common dir (Claude's `/.git/worktrees/` shape marker
|
|
32
|
+
* silently no-ops there; ours resolves it).
|
|
33
|
+
*
|
|
34
|
+
* Root-only by design (Claude sandbox-side parity): a session started in a
|
|
35
|
+
* worktree SUBDIRECTORY keeps the pre-existing behavior — the `.git` file
|
|
36
|
+
* sits only at the worktree root.
|
|
37
|
+
*/
|
|
38
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
39
|
+
import { dirname, join, resolve } from "node:path";
|
|
40
|
+
import { logEvent } from "../errorSink.js";
|
|
41
|
+
import { isDebug } from "../diagnostics.js";
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the shared common git dir when cwd is a linked worktree root.
|
|
44
|
+
* Returns null for every non-worktree / untrusted shape — callers treat
|
|
45
|
+
* null as "no extra entries" and never widen the sandbox.
|
|
46
|
+
*
|
|
47
|
+
* Null REASONS are surfaced (debug level, paths only) whenever a `gitdir:`
|
|
48
|
+
* .git file existed but validation refused: a legitimate worktree that
|
|
49
|
+
* silently misses the allow is indistinguishable from "not a worktree"
|
|
50
|
+
* without them. The plain-repo / no-repo fall-throughs stay silent — they
|
|
51
|
+
* are the common case and carry no signal.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveWorktreeGitAccess(cwd) {
|
|
54
|
+
let gitContent;
|
|
55
|
+
try {
|
|
56
|
+
gitContent = readFileSync(join(cwd, ".git"), "utf-8").trim();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// No .git, or .git is a directory (plain main checkout → EISDIR).
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!gitContent.startsWith("gitdir:"))
|
|
63
|
+
return null;
|
|
64
|
+
// gitdir may be relative (rare, but git accepts it) — resolve against cwd.
|
|
65
|
+
const worktreeGitDir = resolve(cwd, gitContent.slice("gitdir:".length).trim());
|
|
66
|
+
const debugRefused = (reason) => {
|
|
67
|
+
if (!isDebug())
|
|
68
|
+
return;
|
|
69
|
+
logEvent({
|
|
70
|
+
source: "sandbox",
|
|
71
|
+
level: "debug",
|
|
72
|
+
event: "sandbox_worktree_git_refused",
|
|
73
|
+
fields: { reason, worktreeGitDir },
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
let commonContent;
|
|
77
|
+
try {
|
|
78
|
+
// Submodules have a .git file but no commondir (ENOENT → fall through).
|
|
79
|
+
commonContent = readFileSync(join(worktreeGitDir, "commondir"), "utf-8").trim();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
debugRefused("no-commondir");
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const commonDir = resolve(worktreeGitDir, commonContent);
|
|
86
|
+
// Structural check: the worktree gitdir must be a direct child of
|
|
87
|
+
// <commonDir>/worktrees — the commondir we just read must live inside the
|
|
88
|
+
// common dir it named, not at an arbitrary attacker-chosen path. win32
|
|
89
|
+
// compares case-insensitively (git preserves the casing used at worktree
|
|
90
|
+
// creation, so genuine worktrees can carry casing drift between the
|
|
91
|
+
// gitdir spelling and the commondir resolution); POSIX compares exactly.
|
|
92
|
+
const structuralMatches = process.platform === "win32"
|
|
93
|
+
? dirname(worktreeGitDir).toLowerCase() === join(commonDir, "worktrees").toLowerCase()
|
|
94
|
+
: dirname(worktreeGitDir) === join(commonDir, "worktrees");
|
|
95
|
+
if (!structuralMatches) {
|
|
96
|
+
debugRefused("structural-mismatch");
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
// Back-link check: git writes <worktreeGitDir>/gitdir pointing back at
|
|
100
|
+
// this worktree's .git. Realpath the worktree root (not the .git entry —
|
|
101
|
+
// a symlinked .git must not be followed) so legitimate worktrees reached
|
|
102
|
+
// through a symlinked path (macOS /tmp → /private/tmp) still validate.
|
|
103
|
+
// win32: git's strbuf_realpath expands 8.3 short names (C:\\RUNNER~1 →
|
|
104
|
+
// runneradmin) when it writes the back-link, while Node's JS-side
|
|
105
|
+
// realpath does not — both sides go through realpathSync.native (which
|
|
106
|
+
// expands them) and compare case-insensitively, or every genuine
|
|
107
|
+
// worktree under a short-named TEMP would fail closed on spelling.
|
|
108
|
+
// POSIX compares exactly (symlinks already resolved by realpath).
|
|
109
|
+
let rawBacklink;
|
|
110
|
+
try {
|
|
111
|
+
rawBacklink = readFileSync(join(worktreeGitDir, "gitdir"), "utf-8").trim();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
debugRefused("no-backlink");
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
let backlink;
|
|
118
|
+
try {
|
|
119
|
+
backlink = process.platform === "win32" ? realpathSync.native(rawBacklink) : realpathSync(rawBacklink);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Exists but cannot be canonicalized: dangling symlink, permissions —
|
|
123
|
+
// a different failure than an absent back-link, and worth its own code.
|
|
124
|
+
debugRefused("backlink-unreadable");
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
let realCwd;
|
|
128
|
+
try {
|
|
129
|
+
realCwd = process.platform === "win32" ? realpathSync.native(cwd) : realpathSync(cwd);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const backlinkMatches = process.platform === "win32"
|
|
135
|
+
? backlink.toLowerCase() === join(realCwd, ".git").toLowerCase()
|
|
136
|
+
: backlink === join(realCwd, ".git");
|
|
137
|
+
if (!backlinkMatches) {
|
|
138
|
+
debugRefused("backlink-mismatch");
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
commonGitDir: safeRealpath(commonDir),
|
|
143
|
+
worktreeRoot: realCwd,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** realpath that tolerates a vanished path (returns the input spelling) —
|
|
147
|
+
* post-validation only, so a stale spelling can never widen anything: the
|
|
148
|
+
* validation above already proved the entry structure exists. */
|
|
149
|
+
function safeRealpath(p) {
|
|
150
|
+
try {
|
|
151
|
+
return realpathSync(p);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return p;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=worktreeGit.js.map
|
|
@@ -56,6 +56,13 @@ export declare const ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY = "gen_ai.usage.ca
|
|
|
56
56
|
* Unit is NANODOLLARS (integer), not dollars: Datadog copies the value
|
|
57
57
|
* unscaled into its nanodollar cost metric. */
|
|
58
58
|
export declare const ATTR_GEN_AI_COST_ESTIMATED_TOTAL = "gen_ai.cost.estimated_total";
|
|
59
|
+
/** Datadog's OTLP escape hatch: a JSON-object string merged into the LLM
|
|
60
|
+
* Observability span's `meta.metadata`. Its `_dd.cost_tags` list is where
|
|
61
|
+
* Datadog's own SDKs declare which span tags the Cost page may group spend
|
|
62
|
+
* by (the SDK-only `cost_tags` parameter); sending the same shape here is
|
|
63
|
+
* what makes `user.email` a Group-by option on the vendor Cost page.
|
|
64
|
+
* Verified against live probe spans on 2026-09-04. */
|
|
65
|
+
export declare const ATTR_DD_LLMOBS_METADATA = "_dd.ml_obs.metadata";
|
|
59
66
|
export declare const ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id";
|
|
60
67
|
export declare const ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name";
|
|
61
68
|
export declare const ATTR_GEN_AI_TOOL_NAME = "gen_ai.tool.name";
|
|
@@ -58,6 +58,13 @@ export const ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY = "gen_ai.usage.cache_crea
|
|
|
58
58
|
* Unit is NANODOLLARS (integer), not dollars: Datadog copies the value
|
|
59
59
|
* unscaled into its nanodollar cost metric. */
|
|
60
60
|
export const ATTR_GEN_AI_COST_ESTIMATED_TOTAL = "gen_ai.cost.estimated_total";
|
|
61
|
+
/** Datadog's OTLP escape hatch: a JSON-object string merged into the LLM
|
|
62
|
+
* Observability span's `meta.metadata`. Its `_dd.cost_tags` list is where
|
|
63
|
+
* Datadog's own SDKs declare which span tags the Cost page may group spend
|
|
64
|
+
* by (the SDK-only `cost_tags` parameter); sending the same shape here is
|
|
65
|
+
* what makes `user.email` a Group-by option on the vendor Cost page.
|
|
66
|
+
* Verified against live probe spans on 2026-09-04. */
|
|
67
|
+
export const ATTR_DD_LLMOBS_METADATA = "_dd.ml_obs.metadata";
|
|
61
68
|
export const ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id";
|
|
62
69
|
export const ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name";
|
|
63
70
|
export const ATTR_GEN_AI_TOOL_NAME = "gen_ai.tool.name";
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
21
|
import { context as otelContext, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
22
22
|
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
23
|
-
import { ATTR_APP_ENTRYPOINT, ATTR_APP_VERSION, ATTR_ERROR_TYPE, ATTR_GEN_AI_AGENT_NAME, ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_READ_TOKENS, ATTR_GEN_AI_CACHE_READ_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_WRITE_TOKENS, ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_COST_ESTIMATED_TOTAL, ATTR_GEN_AI_FINISH_REASONS, ATTR_GEN_AI_INPUT_TOKENS, ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_OUTPUT_TOKENS, ATTR_GEN_AI_TOTAL_TOKENS, ATTR_GEN_AI_PROVIDER_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_ID, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_TYPE, ATTR_HTTP_STATUS_CODE, ATTR_ORGANIZATION_ID, ATTR_SESSION_ID, ATTR_TERMINAL_TYPE, ATTR_USER_EMAIL, EVENT_API_ERROR, EVENT_API_REQUEST, EVENT_ASSISTANT_RESPONSE, EVENT_PERMISSION_MODE_CHANGED, EVENT_TOOL_DECISION, EVENT_TOOL_RESULT, EVENT_USER_PROMPT, GEN_AI_PROVIDER, languageFromPath, METRIC_ACTIVE_TIME, METRIC_CODE_EDIT_DECISION, METRIC_COMMIT_COUNT, METRIC_COST_USAGE, METRIC_LINES_OF_CODE, METRIC_PULL_REQUEST_COUNT, METRIC_SESSION_COUNT, METRIC_TOKEN_USAGE, PREFIX, SPAN_INTERACTION, SPAN_LLM_REQUEST, SPAN_TOOL, SPAN_TURN, } from "./attrs.js";
|
|
23
|
+
import { ATTR_APP_ENTRYPOINT, ATTR_APP_VERSION, ATTR_ERROR_TYPE, ATTR_GEN_AI_AGENT_NAME, ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_READ_TOKENS, ATTR_GEN_AI_CACHE_READ_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_WRITE_TOKENS, ATTR_DD_LLMOBS_METADATA, ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_COST_ESTIMATED_TOTAL, ATTR_GEN_AI_FINISH_REASONS, ATTR_GEN_AI_INPUT_TOKENS, ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_OUTPUT_TOKENS, ATTR_GEN_AI_TOTAL_TOKENS, ATTR_GEN_AI_PROVIDER_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_ID, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_TYPE, ATTR_HTTP_STATUS_CODE, ATTR_ORGANIZATION_ID, ATTR_SESSION_ID, ATTR_TERMINAL_TYPE, ATTR_USER_EMAIL, EVENT_API_ERROR, EVENT_API_REQUEST, EVENT_ASSISTANT_RESPONSE, EVENT_PERMISSION_MODE_CHANGED, EVENT_TOOL_DECISION, EVENT_TOOL_RESULT, EVENT_USER_PROMPT, GEN_AI_PROVIDER, languageFromPath, METRIC_ACTIVE_TIME, METRIC_CODE_EDIT_DECISION, METRIC_COMMIT_COUNT, METRIC_COST_USAGE, METRIC_LINES_OF_CODE, METRIC_PULL_REQUEST_COUNT, METRIC_SESSION_COUNT, METRIC_TOKEN_USAGE, PREFIX, SPAN_INTERACTION, SPAN_LLM_REQUEST, SPAN_TOOL, SPAN_TURN, } from "./attrs.js";
|
|
24
24
|
/** Idle cutoff for user active time: gaps longer than this are not "active". */
|
|
25
25
|
export const USER_ACTIVE_IDLE_CUTOFF_MS = 5 * 60 * 1000;
|
|
26
26
|
const EDIT_TOOLS = new Set(["edit", "write", "multi_edit", "notebook_edit"]);
|
|
@@ -238,6 +238,13 @@ export class SessionTelemetry {
|
|
|
238
238
|
attrs[ATTR_GEN_AI_REQUEST_MODEL] = model;
|
|
239
239
|
attrs.model = model;
|
|
240
240
|
}
|
|
241
|
+
// Declare `user.email` as a cost-breakdown tag for Datadog's Cost page.
|
|
242
|
+
// Only when the tag is actually on the span (same account gate): Datadog
|
|
243
|
+
// skips entries that reference a missing tag, but an empty declaration
|
|
244
|
+
// is noise. Bounded cardinality by construction: one value per seat.
|
|
245
|
+
if (ATTR_USER_EMAIL in attrs) {
|
|
246
|
+
attrs[ATTR_DD_LLMOBS_METADATA] = JSON.stringify({ _dd: { cost_tags: [ATTR_USER_EMAIL] } });
|
|
247
|
+
}
|
|
241
248
|
const span = this.tracer.startSpan(SPAN_LLM_REQUEST, { attributes: attrs }, parent);
|
|
242
249
|
this.llm = {
|
|
243
250
|
span,
|
package/dist/mcpCommand.d.ts
CHANGED
|
@@ -38,6 +38,13 @@ export interface McpDeps {
|
|
|
38
38
|
}
|
|
39
39
|
/** The structural slice of the extension's mcp config surface this file needs. */
|
|
40
40
|
export interface McpCliModule {
|
|
41
|
+
authenticate?(serverName: string, config: {
|
|
42
|
+
type: "http";
|
|
43
|
+
url: string;
|
|
44
|
+
tools: string[];
|
|
45
|
+
}): Promise<{
|
|
46
|
+
result: "AUTHORIZED";
|
|
47
|
+
}>;
|
|
41
48
|
loadMcpServers(cwd: string, env: NodeJS.ProcessEnv): McpLoadResult;
|
|
42
49
|
mcpConfigPath(): string;
|
|
43
50
|
PROJECT_CONFIG_FILENAME: string;
|
package/dist/mcpCommand.js
CHANGED
|
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
|
|
|
21
21
|
import { CLAUDE_PLUGIN_MCP_ENV, claudeCompatArgs } from "./claudeCompat.js";
|
|
22
22
|
import { agentDir } from "./credentials.js";
|
|
23
23
|
import { DISTRIBUTION } from "./distribution.js";
|
|
24
|
-
import { getActiveProfileName } from "./profiles.js";
|
|
24
|
+
import { getActiveProfileName, readActiveProfile } from "./profiles.js";
|
|
25
25
|
export function resolveMcpConfigPath() {
|
|
26
26
|
const bundled = fileURLToPath(new URL("./extension/mcp/cliConfig.js", import.meta.url));
|
|
27
27
|
if (existsSync(bundled))
|
|
@@ -55,6 +55,9 @@ Scopes:
|
|
|
55
55
|
user available in all your projects (~/.yagni-code/mcp.json)
|
|
56
56
|
project shared via .mcp.json at the repo root (approval-gated)
|
|
57
57
|
|
|
58
|
+
Connect YAGNI Workers:
|
|
59
|
+
${DISTRIBUTION.commandName} mcp connect-workers Browser consent for the active environment
|
|
60
|
+
|
|
58
61
|
Examples:
|
|
59
62
|
${DISTRIBUTION.commandName} mcp add --transport http sentry https://mcp.sentry.dev/mcp
|
|
60
63
|
${DISTRIBUTION.commandName} mcp add -e API_KEY=xxx my-server -- npx my-mcp-server
|
|
@@ -90,7 +93,10 @@ export function parseMcpArgs(argv) {
|
|
|
90
93
|
rest.push(...after.slice(i + 1));
|
|
91
94
|
break;
|
|
92
95
|
}
|
|
93
|
-
if (flag === "s" ||
|
|
96
|
+
if (flag === "s" ||
|
|
97
|
+
flag === "t" ||
|
|
98
|
+
flag === "client-id" ||
|
|
99
|
+
flag === "callback-port") {
|
|
94
100
|
if (flag === "s") {
|
|
95
101
|
scope = scopeFrom(arg);
|
|
96
102
|
scopeExplicit = true;
|
|
@@ -204,11 +210,28 @@ export function parseMcpArgs(argv) {
|
|
|
204
210
|
}
|
|
205
211
|
rest.push(arg);
|
|
206
212
|
}
|
|
207
|
-
if (flag === "s" ||
|
|
213
|
+
if (flag === "s" ||
|
|
214
|
+
flag === "t" ||
|
|
215
|
+
flag === "client-id" ||
|
|
216
|
+
flag === "callback-port") {
|
|
208
217
|
throw new Error("Missing flag value.");
|
|
209
218
|
}
|
|
210
219
|
const [name, ...commandArgs] = rest;
|
|
211
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
subcommand,
|
|
222
|
+
name,
|
|
223
|
+
rest,
|
|
224
|
+
scope,
|
|
225
|
+
scopeExplicit,
|
|
226
|
+
transport,
|
|
227
|
+
transportExplicit,
|
|
228
|
+
env,
|
|
229
|
+
headers,
|
|
230
|
+
commandArgs,
|
|
231
|
+
clientId,
|
|
232
|
+
clientSecret,
|
|
233
|
+
callbackPort,
|
|
234
|
+
};
|
|
212
235
|
}
|
|
213
236
|
function scopeFrom(value) {
|
|
214
237
|
if (value === "local" || value === "user" || value === "project")
|
|
@@ -236,7 +259,11 @@ function describeScopePath(scope, cwd) {
|
|
|
236
259
|
async function defaultPluginMcpEnv(cwd) {
|
|
237
260
|
try {
|
|
238
261
|
const profile = await getActiveProfileName();
|
|
239
|
-
const compat = await claudeCompatArgs({
|
|
262
|
+
const compat = await claudeCompatArgs({
|
|
263
|
+
cwd,
|
|
264
|
+
agentDir: agentDir(profile),
|
|
265
|
+
interactive: false,
|
|
266
|
+
});
|
|
240
267
|
return compat.env[CLAUDE_PLUGIN_MCP_ENV];
|
|
241
268
|
}
|
|
242
269
|
catch {
|
|
@@ -272,6 +299,8 @@ export async function mcpCommand(args, deps = {}) {
|
|
|
272
299
|
return 1;
|
|
273
300
|
}
|
|
274
301
|
switch (parsed.subcommand) {
|
|
302
|
+
case "connect-workers":
|
|
303
|
+
return connectWorkers(mod, parsed, { cwd, stdout, stderr }, deps.env ?? process.env);
|
|
275
304
|
case undefined:
|
|
276
305
|
case "help":
|
|
277
306
|
stdout(USAGE);
|
|
@@ -283,18 +312,105 @@ export async function mcpCommand(args, deps = {}) {
|
|
|
283
312
|
case "remove":
|
|
284
313
|
return mcpRemove(mod, parsed, { cwd, stdout, stderr });
|
|
285
314
|
case "list":
|
|
286
|
-
return mcpList(mod, {
|
|
315
|
+
return mcpList(mod, {
|
|
316
|
+
cwd,
|
|
317
|
+
stdout,
|
|
318
|
+
stderr,
|
|
319
|
+
env: await envWithPluginMcp(deps, cwd),
|
|
320
|
+
probeServer: deps.probeServer,
|
|
321
|
+
});
|
|
287
322
|
case "get":
|
|
288
|
-
return mcpGet(mod, parsed, {
|
|
323
|
+
return mcpGet(mod, parsed, {
|
|
324
|
+
cwd,
|
|
325
|
+
stdout,
|
|
326
|
+
stderr,
|
|
327
|
+
env: await envWithPluginMcp(deps, cwd),
|
|
328
|
+
probeServer: deps.probeServer,
|
|
329
|
+
});
|
|
289
330
|
case "reset-project-choices":
|
|
290
331
|
return mcpResetChoices(mod, { cwd, stdout, stderr });
|
|
291
332
|
case "add-from-claude":
|
|
292
|
-
return mcpAddFromClaude(mod, parsed, {
|
|
333
|
+
return mcpAddFromClaude(mod, parsed, {
|
|
334
|
+
cwd,
|
|
335
|
+
stdout,
|
|
336
|
+
stderr,
|
|
337
|
+
home: deps.home ?? homedir(),
|
|
338
|
+
});
|
|
293
339
|
default:
|
|
294
340
|
stderr(`Unknown mcp subcommand "${parsed.subcommand}".\n${USAGE}`);
|
|
295
341
|
return 1;
|
|
296
342
|
}
|
|
297
343
|
}
|
|
344
|
+
const WORKER_TOOL_NAMES = [
|
|
345
|
+
"get_context",
|
|
346
|
+
"create_team",
|
|
347
|
+
"engage_worker",
|
|
348
|
+
"critique_plan",
|
|
349
|
+
"review_pr",
|
|
350
|
+
"test_pr",
|
|
351
|
+
"get_qa_evidence",
|
|
352
|
+
"get_qa_replay",
|
|
353
|
+
"validate_qa_replay",
|
|
354
|
+
"accept_qa_replay",
|
|
355
|
+
"authorize_qa_fork",
|
|
356
|
+
"list_work",
|
|
357
|
+
"get_work",
|
|
358
|
+
"add_feedback",
|
|
359
|
+
"propose_instruction_change",
|
|
360
|
+
"prepare_review_publication",
|
|
361
|
+
"resolve_decision",
|
|
362
|
+
];
|
|
363
|
+
async function connectWorkers(mod, parsed, io, env) {
|
|
364
|
+
if (parsed.rest.length ||
|
|
365
|
+
parsed.scopeExplicit ||
|
|
366
|
+
parsed.transportExplicit ||
|
|
367
|
+
Object.keys(parsed.headers).length ||
|
|
368
|
+
Object.keys(parsed.env).length ||
|
|
369
|
+
parsed.clientId ||
|
|
370
|
+
parsed.clientSecret ||
|
|
371
|
+
parsed.callbackPort) {
|
|
372
|
+
io.stderr("Usage: yagni mcp connect-workers (uses your active environment and private user configuration)\n");
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
if (!mod.authenticate)
|
|
377
|
+
throw new Error("Update YAGNI Code to connect Workers");
|
|
378
|
+
const profile = await readActiveProfile(env);
|
|
379
|
+
const url = new URL("/mcp", profile.baseUrl);
|
|
380
|
+
if (url.username ||
|
|
381
|
+
url.password ||
|
|
382
|
+
(url.protocol !== "https:" &&
|
|
383
|
+
!(url.protocol === "http:" &&
|
|
384
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))))
|
|
385
|
+
throw new Error("Worker connections require HTTPS or a local development server");
|
|
386
|
+
const config = {
|
|
387
|
+
type: "http",
|
|
388
|
+
url: url.href,
|
|
389
|
+
tools: WORKER_TOOL_NAMES,
|
|
390
|
+
};
|
|
391
|
+
const { file, errors } = mod.readUserMcpConfig();
|
|
392
|
+
if (errors.length)
|
|
393
|
+
throw new Error("Fix the existing MCP configuration before connecting Workers");
|
|
394
|
+
const previous = file
|
|
395
|
+
.mcpServers?.["yagni-workers"];
|
|
396
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(config))
|
|
397
|
+
throw new Error("An existing yagni-workers connection uses different settings. Remove it with yagni mcp remove yagni-workers before reconnecting");
|
|
398
|
+
const shadow = mod
|
|
399
|
+
.loadMcpServers(io.cwd, env)
|
|
400
|
+
.servers.find((server) => server.name === "yagni-workers" && server.scope !== "user");
|
|
401
|
+
if (shadow)
|
|
402
|
+
throw new Error("A project or local yagni-workers entry would override this connection. Remove that entry before connecting");
|
|
403
|
+
io.stdout("Opening YAGNI in your browser. Choose your workspace and permissions; paid Worker requests use that workspace's balance.\n");
|
|
404
|
+
await mod.authenticate("yagni-workers", config);
|
|
405
|
+
writeServerToScope(mod, "yagni-workers", config, "user", io.cwd);
|
|
406
|
+
io.stdout("Workers connected. Start a new YAGNI Code session to use them; /mcp shows connection status.\n");
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
io.stderr("Worker connection did not complete. Check your environment and existing yagni-workers configuration, then retry browser consent.\n");
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
298
414
|
function mcpAdd(mod, parsed, io) {
|
|
299
415
|
const { name, rest } = parsed;
|
|
300
416
|
const commandOrUrl = rest[1];
|
|
@@ -308,7 +424,9 @@ function mcpAdd(mod, parsed, io) {
|
|
|
308
424
|
serverConfig = {
|
|
309
425
|
type: parsed.transport,
|
|
310
426
|
url: commandOrUrl,
|
|
311
|
-
...(Object.keys(parsed.headers).length > 0
|
|
427
|
+
...(Object.keys(parsed.headers).length > 0
|
|
428
|
+
? { headers: parsed.headers }
|
|
429
|
+
: {}),
|
|
312
430
|
...(oauthBlock(parsed) ? { oauth: oauthBlock(parsed) } : {}),
|
|
313
431
|
};
|
|
314
432
|
}
|
|
@@ -398,18 +516,29 @@ function readClientSecret(io) {
|
|
|
398
516
|
function validateConfigShape(config) {
|
|
399
517
|
const type = config["type"];
|
|
400
518
|
if (type === undefined || type === "stdio") {
|
|
401
|
-
if (typeof config["command"] !== "string" ||
|
|
402
|
-
|
|
519
|
+
if (typeof config["command"] !== "string" ||
|
|
520
|
+
config["command"].length === 0) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
message: 'stdio server requires a non-empty "command"',
|
|
524
|
+
};
|
|
403
525
|
}
|
|
404
526
|
return { ok: true };
|
|
405
527
|
}
|
|
406
528
|
if (type === "http" || type === "sse") {
|
|
407
|
-
if (typeof config["url"] !== "string" ||
|
|
408
|
-
|
|
529
|
+
if (typeof config["url"] !== "string" ||
|
|
530
|
+
config["url"].length === 0) {
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
message: `${type} server requires a non-empty "url"`,
|
|
534
|
+
};
|
|
409
535
|
}
|
|
410
536
|
return { ok: true };
|
|
411
537
|
}
|
|
412
|
-
return {
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
message: 'unknown "type" — expected stdio, http, or sse',
|
|
541
|
+
};
|
|
413
542
|
}
|
|
414
543
|
function writeServerToScope(mod, name, config, scope, cwd) {
|
|
415
544
|
if (scope === "project") {
|
|
@@ -584,7 +713,9 @@ async function mcpGet(mod, parsed, io) {
|
|
|
584
713
|
else {
|
|
585
714
|
io.stdout(` Type: stdio\n`);
|
|
586
715
|
io.stdout(` Command: ${config["command"]}\n`);
|
|
587
|
-
const args = Array.isArray(config["args"])
|
|
716
|
+
const args = Array.isArray(config["args"])
|
|
717
|
+
? config["args"]
|
|
718
|
+
: [];
|
|
588
719
|
if (args.length > 0)
|
|
589
720
|
io.stdout(` Args: ${args.join(" ")}\n`);
|
|
590
721
|
for (const [key, value] of Object.entries(config["env"] ?? {})) {
|
|
@@ -610,7 +741,9 @@ function printOAuthDetail(mod, name, config, stdout) {
|
|
|
610
741
|
const cfg = config;
|
|
611
742
|
const oauth = cfg["oauth"] ?? {};
|
|
612
743
|
const clientId = typeof oauth["clientId"] === "string" ? oauth["clientId"] : undefined;
|
|
613
|
-
const callbackPort = typeof oauth["callbackPort"] === "number"
|
|
744
|
+
const callbackPort = typeof oauth["callbackPort"] === "number"
|
|
745
|
+
? oauth["callbackPort"]
|
|
746
|
+
: undefined;
|
|
614
747
|
const stored = mod.getStoredOAuthEntry(name, config);
|
|
615
748
|
if (clientId || callbackPort || stored?.clientSecret) {
|
|
616
749
|
stdout(` OAuth: client_id ${clientId ? "configured" : "(DCR)"}, client_secret ${stored?.clientSecret ? "configured" : "not set"}${callbackPort ? `, callback_port ${callbackPort}` : ""}\n`);
|
|
@@ -642,10 +775,20 @@ async function mcpList(mod, io) {
|
|
|
642
775
|
return 0;
|
|
643
776
|
}
|
|
644
777
|
const lines = [];
|
|
645
|
-
const byScope = {
|
|
778
|
+
const byScope = {
|
|
779
|
+
user: [],
|
|
780
|
+
project: [],
|
|
781
|
+
local: [],
|
|
782
|
+
plugin: [],
|
|
783
|
+
};
|
|
646
784
|
for (const s of servers)
|
|
647
785
|
(byScope[s.scope] ??= []).push(s);
|
|
648
|
-
const labels = {
|
|
786
|
+
const labels = {
|
|
787
|
+
local: "Local",
|
|
788
|
+
project: "Project",
|
|
789
|
+
user: "User",
|
|
790
|
+
plugin: "Plugin",
|
|
791
|
+
};
|
|
649
792
|
for (const scope of ["local", "project", "user", "plugin"]) {
|
|
650
793
|
const group = byScope[scope];
|
|
651
794
|
if (!group?.length)
|
package/dist/refresh.js
CHANGED
|
@@ -108,8 +108,11 @@ export async function maybeRefreshAtLaunch(creds, deps = {}) {
|
|
|
108
108
|
await deps.persist(next);
|
|
109
109
|
}
|
|
110
110
|
catch {
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
return {
|
|
112
|
+
creds: next,
|
|
113
|
+
refreshed: true,
|
|
114
|
+
warnings: ["Your refreshed YAGNI Code session could not be saved. Check disk space and profile permissions, then run yagni login before the next session."],
|
|
115
|
+
};
|
|
113
116
|
}
|
|
114
117
|
}
|
|
115
118
|
return { creds: next, refreshed: true, warnings: [] };
|
package/dist/token.d.ts
CHANGED
|
@@ -15,11 +15,12 @@ import type { Credentials } from "./credentials.js";
|
|
|
15
15
|
import { type Profile } from "./profiles.js";
|
|
16
16
|
export interface TokenCommandDeps {
|
|
17
17
|
readProfile?: () => Promise<Profile>;
|
|
18
|
+
readNamedProfile?: (name: string) => Promise<Profile | null>;
|
|
18
19
|
refresh?: typeof maybeRefreshAtLaunch;
|
|
19
20
|
persist?: (name: string, creds: Credentials) => Promise<void>;
|
|
20
21
|
now?: () => number;
|
|
21
22
|
stdout?: (text: string) => void;
|
|
22
23
|
stderr?: (text: string) => void;
|
|
23
24
|
}
|
|
24
|
-
export declare function tokenCommand(deps?: TokenCommandDeps): Promise<number>;
|
|
25
|
+
export declare function tokenCommand(deps?: TokenCommandDeps, args?: string[]): Promise<number>;
|
|
25
26
|
//# sourceMappingURL=token.d.ts.map
|