dsh-agy-cli 0.0.0-stage → 0.12.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.
Files changed (91) hide show
  1. package/LICENSE +30 -0
  2. package/README.en.md +27 -0
  3. package/README.md +94 -2
  4. package/agents/dsh-agy-image-view.md +17 -0
  5. package/agents/dsh-agy-mcp-executor.md +32 -0
  6. package/agents/dsh-agy-read-only.md +20 -0
  7. package/agents/dsh-agy-tool-free.md +16 -0
  8. package/agents/dsh-agy-workspace-write.md +23 -0
  9. package/bin/dsh-agy-cli.js +9 -0
  10. package/bin/win32-x64/agy-launcher.exe +0 -0
  11. package/cordis.patch.yml +16 -0
  12. package/dist/client.js +4259 -0
  13. package/lib/agent-installer.d.ts +66 -0
  14. package/lib/agent-installer.js +147 -0
  15. package/lib/agent-presets.d.ts +42 -0
  16. package/lib/agent-presets.js +130 -0
  17. package/lib/agents-cli.d.ts +3 -0
  18. package/lib/agents-cli.js +110 -0
  19. package/lib/agy/diagnostics.d.ts +29 -0
  20. package/lib/agy/diagnostics.js +109 -0
  21. package/lib/agy/errors.d.ts +20 -0
  22. package/lib/agy/errors.js +77 -0
  23. package/lib/agy/experimental-transport.d.ts +79 -0
  24. package/lib/agy/experimental-transport.js +612 -0
  25. package/lib/agy/keepalive.d.ts +140 -0
  26. package/lib/agy/keepalive.js +424 -0
  27. package/lib/agy/limiter.d.ts +32 -0
  28. package/lib/agy/limiter.js +111 -0
  29. package/lib/agy/log.d.ts +214 -0
  30. package/lib/agy/log.js +503 -0
  31. package/lib/agy/models.d.ts +49 -0
  32. package/lib/agy/models.js +203 -0
  33. package/lib/agy/overrides.d.ts +17 -0
  34. package/lib/agy/overrides.js +73 -0
  35. package/lib/agy/parser.d.ts +102 -0
  36. package/lib/agy/parser.js +492 -0
  37. package/lib/agy/persistent-transport.d.ts +101 -0
  38. package/lib/agy/persistent-transport.js +753 -0
  39. package/lib/agy/process.d.ts +144 -0
  40. package/lib/agy/process.js +452 -0
  41. package/lib/agy/redact.d.ts +3 -0
  42. package/lib/agy/redact.js +24 -0
  43. package/lib/agy/stream-protocol.d.ts +50 -0
  44. package/lib/agy/stream-protocol.js +156 -0
  45. package/lib/agy/windows-launcher.d.ts +21 -0
  46. package/lib/agy/windows-launcher.js +76 -0
  47. package/lib/diagnostics.d.ts +78 -0
  48. package/lib/diagnostics.js +162 -0
  49. package/lib/doctor.d.ts +138 -0
  50. package/lib/doctor.js +923 -0
  51. package/lib/dsh/context.d.ts +133 -0
  52. package/lib/dsh/context.js +352 -0
  53. package/lib/dsh-home.d.ts +6 -0
  54. package/lib/dsh-home.js +11 -0
  55. package/lib/index.d.ts +43 -0
  56. package/lib/index.js +256 -0
  57. package/lib/mcp/agy-registration.d.ts +70 -0
  58. package/lib/mcp/agy-registration.js +245 -0
  59. package/lib/mcp/bridge.d.ts +73 -0
  60. package/lib/mcp/bridge.js +295 -0
  61. package/lib/mcp/endpoint.d.ts +41 -0
  62. package/lib/mcp/endpoint.js +142 -0
  63. package/lib/mcp/protocol.d.ts +107 -0
  64. package/lib/mcp/protocol.js +197 -0
  65. package/lib/mcp/tool-projection.d.ts +64 -0
  66. package/lib/mcp/tool-projection.js +168 -0
  67. package/lib/provider/agy.d.ts +153 -0
  68. package/lib/provider/agy.js +2159 -0
  69. package/lib/provider/config.d.ts +226 -0
  70. package/lib/provider/config.js +294 -0
  71. package/lib/provider/error-codes.d.ts +22 -0
  72. package/lib/provider/error-codes.js +29 -0
  73. package/lib/provider/image-bridge.d.ts +60 -0
  74. package/lib/provider/image-bridge.js +290 -0
  75. package/lib/provider/mock.d.ts +15 -0
  76. package/lib/provider/mock.js +78 -0
  77. package/lib/provider/prompt-budget.d.ts +45 -0
  78. package/lib/provider/prompt-budget.js +596 -0
  79. package/lib/provider/serialize.d.ts +15 -0
  80. package/lib/provider/serialize.js +82 -0
  81. package/lib/provider/tool-protocol.d.ts +80 -0
  82. package/lib/provider/tool-protocol.js +848 -0
  83. package/lib/provider/tool-schema-file.d.ts +11 -0
  84. package/lib/provider/tool-schema-file.js +27 -0
  85. package/lib/session/file-store.d.ts +56 -0
  86. package/lib/session/file-store.js +186 -0
  87. package/lib/session/store.d.ts +49 -0
  88. package/lib/session/store.js +62 -0
  89. package/lib/web/router.d.ts +19 -0
  90. package/lib/web/router.js +906 -0
  91. package/package.json +112 -4
package/lib/index.js ADDED
@@ -0,0 +1,256 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import { AgyAdapter } from './provider/agy.js';
3
+ import { BundleConfig, Config as ConfigSchema, createConfigSchema, } from './provider/config.js';
4
+ import { MockAdapter } from './provider/mock.js';
5
+ import { AgyModelDiscovery } from './agy/models.js';
6
+ import { appendAgyDiagnosticRecord, AGY_DIAGNOSTIC_LOG_PATH } from './agy/log.js';
7
+ import { configuredModels, readConfigFlag } from './provider/config.js';
8
+ import { SessionRegistry } from './session/store.js';
9
+ import { FileSessionStore, resolveSessionStorePath } from './session/file-store.js';
10
+ export const name = 'dsh-agy-cli';
11
+ export const inject = ['llm', 'sessions', 'workspaceRegistry', 'sandboxPolicy', 'permissionPresets', 'approval'];
12
+ /** Programmatic library default: enabled=false, toolPolicy=reject. */
13
+ export const Config = ConfigSchema;
14
+ export { AgyAdapter, MockAdapter };
15
+ export { AgyModelDiscovery, mergeModelCatalog, parseAgyModels } from './agy/models.js';
16
+ export { diagnoseAgy } from './agy/diagnostics.js';
17
+ export { diagnoseProvider } from './diagnostics.js';
18
+ export { DshContextError, diagnoseDshContext, readDshContextServices, resolveDshContext } from './dsh/context.js';
19
+ export { appendToolProtocolPrompt, createStructuredToolProtocol, parseStructuredEnvelope, renderToolProtocolPrompt, StructuredResponseAccumulator, ToolProtocolError, TOOL_PROTOCOL_LIMITS, } from './provider/tool-protocol.js';
20
+ export { stageToolSchema } from './provider/tool-schema-file.js';
21
+ export { diagnoseProfile, runDoctor } from './doctor.js';
22
+ export { AgyImageBridgeError, DEFAULT_IMAGE_MAX_BYTES, DEFAULT_IMAGE_MAX_COUNT, IMAGE_MEDIA_TYPES, prepareAgyPrompts, } from './provider/image-bridge.js';
23
+ import { startDshMcpBridge, } from './mcp/bridge.js';
24
+ import { noteRegistration, setupWebRoutes } from './web/router.js';
25
+ import { findMissingAgentTemplates, } from './agent-installer.js';
26
+ import { agentTemplatesForRequest } from './agent-presets.js';
27
+ import { AGENT_PRESET_IDS, getAgentPreset, listAgentPresets, readAgentPresetTemplate, requireAgentPreset, } from './agent-presets.js';
28
+ export { AGENT_PRESET_IDS, agentTemplatesForRequest, getAgentPreset, listAgentPresets, readAgentPresetTemplate, requireAgentPreset, };
29
+ export { DEFAULT_AGENT_DIRECTORY, AgentInstallError, describeAgentPresets, findMissingAgentTemplates, installAgentPreset, isAgentPreset, } from './agent-installer.js';
30
+ export { formatAgentsHelp, runAgentsCli } from './agents-cli.js';
31
+ export { BundleConfig, ConfigSchema, createConfigSchema };
32
+ function resolveSettingsConfig(ctx, config) {
33
+ // DSH 0.2.0 removed SettingsProvider.register(): the harness's SettingsForms
34
+ // projects the plugin's exported `Config` schema automatically, and `config`
35
+ // already is the live merged value. Direct library callers compose the LLM
36
+ // service alone, which keeps working unchanged.
37
+ void ctx;
38
+ return config;
39
+ }
40
+ export function apply(ctx, config) {
41
+ const activeConfig = resolveSettingsConfig(ctx, config);
42
+ const modelDiscovery = activeConfig.modelDiscovery === 'off' ? undefined : new AgyModelDiscovery({
43
+ ...(activeConfig.agyPath?.trim() ? { executable: activeConfig.agyPath.trim() } : {}),
44
+ ...(activeConfig.modelDiscoveryTtlMs === undefined ? {} : { ttlMs: activeConfig.modelDiscoveryTtlMs }),
45
+ ...(activeConfig.modelDiscoveryTimeoutMs === undefined ? {} : { timeoutMs: activeConfig.modelDiscoveryTimeoutMs }),
46
+ });
47
+ // Settings panel: expose the local AGY CLI as the Antigravity CLI provider.
48
+ // The namespace is the plugin name; settingsPath [] means the whole section is the profile.
49
+ //
50
+ // The outcome is recorded, not discarded: the Models page attaches the AGY
51
+ // card only to a directory row whose `settingsNs` is exactly this string, and
52
+ // a refused registration (DUPLICATE_DIRECTORY when this profile mounts the
53
+ // plugin twice) used to be swallowed — which made "no card on the Models page"
54
+ // indistinguishable from "card rendered empty". `/plugins/agy-cli/diagnose`
55
+ // now reports both the refusal and the directory it actually has.
56
+ const registerProviderDirectory = () => {
57
+ try {
58
+ ctx.llm.registerConfigurableProviders([{
59
+ provider: 'agy',
60
+ displayName: 'Antigravity CLI',
61
+ settingsNs: 'dsh-agy-cli',
62
+ settingsPath: [],
63
+ }]);
64
+ noteRegistration('configurableProviders', 'ok');
65
+ return true;
66
+ }
67
+ catch (error) {
68
+ noteRegistration('configurableProviders', error instanceof Error ? error.message : String(error));
69
+ return false;
70
+ }
71
+ };
72
+ if (!registerProviderDirectory()) {
73
+ // Why a retry: a refused registration means another mount of this plugin
74
+ // already holds the `agy` entry. If that mount is disposed afterwards (the
75
+ // bundle mount being replaced by the patch override), the directory loses the
76
+ // entry and the Models page keeps listing the provider with an EMPTY
77
+ // settingsNs — a row that renders no card. Retrying once after the loader
78
+ // settles repairs that order dependence without a host restart.
79
+ ctx.effect(() => {
80
+ const timers = [3_000, 10_000].map((delay) => {
81
+ const timer = setTimeout(() => { registerProviderDirectory(); }, delay);
82
+ if (typeof timer.unref === 'function')
83
+ timer.unref();
84
+ return timer;
85
+ });
86
+ return () => { for (const timer of timers)
87
+ clearTimeout(timer); };
88
+ });
89
+ }
90
+ try {
91
+ ctx.llm.registerModelDiscovery('dsh-agy-cli', async (request) => {
92
+ // If editing an existing agy route, prefer our already-known catalog; otherwise discover via AGY CLI.
93
+ // request.provider being 'agy' or undefined both mean this plugin owns the draft.
94
+ if (request.provider !== undefined && request.provider !== 'agy')
95
+ return [];
96
+ const configured = configuredModels(activeConfig);
97
+ const result = activeConfig.modelDiscovery === 'off'
98
+ ? { models: configured, source: 'static', stale: false }
99
+ : await modelDiscovery.discover(configured);
100
+ return result.models.map(m => ({
101
+ id: m.id,
102
+ ...(m.name === undefined ? {} : { name: m.name }),
103
+ ...(m.contextWindow === undefined ? {} : { contextWindow: m.contextWindow }),
104
+ }));
105
+ });
106
+ noteRegistration('modelDiscovery', 'ok');
107
+ }
108
+ catch (error) {
109
+ noteRegistration('modelDiscovery', error instanceof Error ? error.message : String(error));
110
+ }
111
+ // Mount web HTTP routes for GUI control panel & status polling (/plugins/agy-cli/*)
112
+ try {
113
+ setupWebRoutes(ctx, activeConfig, modelDiscovery);
114
+ noteRegistration('webRoutes', 'ok');
115
+ }
116
+ catch (error) {
117
+ noteRegistration('webRoutes', error instanceof Error ? error.message : String(error));
118
+ }
119
+ // Settings are resolved before the enabled check, so disabled profiles can
120
+ // still be edited from the configuration surface.
121
+ if (!readConfigFlag(activeConfig.enabled))
122
+ return;
123
+ const provider = activeConfig.provider ?? 'agy';
124
+ if (provider === 'agy-mock') {
125
+ ctx.llm.registerAdapter([provider], new MockAdapter(activeConfig));
126
+ return;
127
+ }
128
+ const logger = ctx.logger('dsh-agy-cli');
129
+ // AGY exits with `unknown agent "<name>" (available: ...)` *before* it reads
130
+ // any input when the profile named on the command line has no template on
131
+ // disk. The child then dies while its stdin is being written, and the adapter
132
+ // reports that as AGY_INPUT_FAILED — an error naming the wrong component.
133
+ // Report the real cause once, at startup, naming the exact missing files.
134
+ // This only warns: installing templates is a deliberate user action, and
135
+ // writing into another application's config directory unprompted is not
136
+ // something a provider should do.
137
+ ctx.effect(() => {
138
+ let cancelled = false;
139
+ void (async () => {
140
+ try {
141
+ const wanted = agentTemplatesForRequest({
142
+ toolPolicy: activeConfig.toolPolicy === 'agy-owned'
143
+ ? 'agy-owned'
144
+ : activeConfig.toolPolicy === 'dsh-owned' ? 'dsh-owned' : 'reject',
145
+ agentPreset: activeConfig.agentPreset,
146
+ agent: activeConfig.agent,
147
+ imageInput: activeConfig.imageInput === 'experimental' ? 'experimental' : 'off',
148
+ });
149
+ const missing = (await Promise.all(wanted.map(async (entry) => {
150
+ if (entry.preset === undefined)
151
+ return undefined;
152
+ const absent = await findMissingAgentTemplates([entry.preset]);
153
+ return absent.length === 0 ? undefined : absent[0];
154
+ }))).filter((entry) => entry !== undefined);
155
+ if (cancelled || missing.length === 0)
156
+ return;
157
+ logger.warn('AGY agent template(s) missing: %s. AGY rejects an unknown agent before reading any input, '
158
+ + 'which surfaces as AGY_INPUT_FAILED. Install them with: npx dsh-agy-cli agents install %s --apply', missing.map(entry => `${entry.agentName} (${entry.targetPath})`).join(', '), missing.map(entry => entry.preset).join(' '));
159
+ }
160
+ catch (error) {
161
+ logger.warn('could not check AGY agent templates: %s', error instanceof Error ? error.message : String(error));
162
+ }
163
+ })();
164
+ return () => {
165
+ cancelled = true;
166
+ };
167
+ });
168
+ // AttachmentStore is optional for the text-only path. Use the reflection
169
+ // lookup instead of reading ctx.attachments directly: Cordis requires every
170
+ // direct service property access to be declared in `inject`.
171
+ // The MCP tool bridge hands DSH tools to AGY out of band (so their schemas
172
+ // never enter the prompt) and routes every execution back through the host's
173
+ // own tool pipeline. It starts as an effect so the registration is removed
174
+ // again when this fiber unloads.
175
+ let toolBridge;
176
+ let toolBridgeError;
177
+ const wantsBridge = activeConfig.mcpBridge !== false
178
+ && activeConfig.toolPolicy === 'dsh-owned';
179
+ if (wantsBridge) {
180
+ ctx.effect(() => {
181
+ let cancelled = false;
182
+ void (async () => {
183
+ const bridge = await startDshMcpBridge({
184
+ webServer: ctx.get('webServer'),
185
+ tools: ctx.get('tools'),
186
+ // Late-bound on purpose: this plugin loads before the Session
187
+ // controller bundle finishes mounting, and a value captured here
188
+ // stays undefined for the life of the host.
189
+ sessionControllerLookup: () => ctx.get('sessionController'),
190
+ ...((activeConfig.mcpToolAllowlist ?? []).length > 0 ? { toolAllowlist: activeConfig.mcpToolAllowlist } : {}),
191
+ serverVersion: '0.0.0',
192
+ logger: message => {
193
+ logger.info(message);
194
+ // The host logger has no file sink, so a bridge that silently loses
195
+ // the session agent leaves no evidence. Mirror into the diagnostic
196
+ // log the rest of this provider already writes.
197
+ try {
198
+ appendFileSync(AGY_DIAGNOSTIC_LOG_PATH, `${JSON.stringify({ event: 'bridge.log', at: Date.now(), message })}\n`, 'utf8');
199
+ }
200
+ catch {
201
+ // Diagnostics must never break the bridge.
202
+ }
203
+ },
204
+ });
205
+ if (cancelled) {
206
+ await bridge.dispose();
207
+ return;
208
+ }
209
+ toolBridge = bridge;
210
+ if (!bridge.active) {
211
+ toolBridgeError = bridge.inactiveReason;
212
+ logger.warn('dsh mcp bridge inactive: %s', bridge.inactiveReason ?? 'unknown reason');
213
+ }
214
+ })().catch(error => {
215
+ toolBridgeError = error instanceof Error ? error.message : String(error);
216
+ logger.warn('dsh mcp bridge failed to start: %s', toolBridgeError);
217
+ });
218
+ return async () => {
219
+ cancelled = true;
220
+ const bridge = toolBridge;
221
+ toolBridge = undefined;
222
+ await bridge?.dispose();
223
+ };
224
+ });
225
+ }
226
+ ctx.llm.registerAdapter([provider], new AgyAdapter(activeConfig, {
227
+ logger: record => {
228
+ try {
229
+ logger.info('%s', JSON.stringify(record));
230
+ }
231
+ finally {
232
+ appendAgyDiagnosticRecord(record);
233
+ }
234
+ },
235
+ // With sessionMode 'resume' the sessionId -> conversationId map must outlive
236
+ // the plugin: AGY keeps every conversation on disk, so a durable map turns
237
+ // the first turn after a host restart from a full-history re-serialization
238
+ // (pinned at the input limit, missing the upstream prompt cache) into an
239
+ // incremental one. 'full' never reads the map, so it stays in memory.
240
+ ...(activeConfig.sessionMode === 'resume'
241
+ ? { sessionRegistry: new SessionRegistry(new FileSessionStore({ path: resolveSessionStorePath(activeConfig.sessionStorePath) })) }
242
+ : {}),
243
+ // Resolve lazily because attachments is optional and can be composed after
244
+ // this plugin. Text-only requests remain independent of the service.
245
+ resolveAttachmentStore: () => ctx.get('attachments'),
246
+ resolveToolBridge: () => {
247
+ if (!wantsBridge)
248
+ return undefined;
249
+ if (toolBridge?.active === true)
250
+ return toolBridge;
251
+ return undefined;
252
+ },
253
+ dshContext: ctx,
254
+ }));
255
+ }
256
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,70 @@
1
+ /** The MCP server name AGY shows for this bridge. */
2
+ export declare const AGY_MCP_SERVER_NAME = "dsh";
3
+ export interface AgyMcpEntry {
4
+ readonly url: string;
5
+ readonly token: string;
6
+ /** Exposed tool names, used to scope the AGY-side allow rules. */
7
+ readonly toolNames?: readonly string[];
8
+ }
9
+ export interface AgyMcpConfigFile {
10
+ readonly path: string;
11
+ readonly root: Record<string, unknown>;
12
+ }
13
+ export interface AgyMcpRegistrationOptions {
14
+ /** Overrides the AGY MCP-config root; tests pass a temporary directory. */
15
+ readonly configRoot?: string;
16
+ /**
17
+ * Overrides the AGY settings root. Settings and MCP config live one level
18
+ * apart, so this is a separate knob; defaulting it to `configRoot` keeps
19
+ * single-directory test fixtures working.
20
+ */
21
+ readonly settingsRoot?: string;
22
+ readonly logger?: (message: string) => void;
23
+ }
24
+ /**
25
+ * The directory AGY reads `mcp_config.json` and `agents/` from.
26
+ *
27
+ * It is the `gemini-root-config` child of the CLI data directory. `agy mcp
28
+ * list`, `agy agents`, and `agy mcp add` all read and write this nested root —
29
+ * verified by adding a server with the CLI and watching only the nested copy
30
+ * change on disk.
31
+ *
32
+ * `HOME`/`USERPROFILE` overrides do not relocate this either: the CLI resolves
33
+ * it through the Windows known-folder API, so tests must pass `configRoot`
34
+ * rather than trying to sandbox the process environment.
35
+ */
36
+ export declare function agyConfigRoot(): string;
37
+ /**
38
+ * The directory AGY reads `settings.json` from.
39
+ *
40
+ * Settings live one level *above* the MCP config, not beside it. Measured with
41
+ * a signed probe rule: a rule added to the nested `settings.json` never reaches
42
+ * the `Allow:[...]` table AGY logs at startup, while the same rule added to the
43
+ * outer file shows up immediately. Only the outer file is ever loaded.
44
+ *
45
+ * Getting this wrong is silent in the worst way: the nested file looks correct
46
+ * on disk, and every MCP tool call is then auto-denied in headless mode. AGY
47
+ * logs the real table as `CLI settings initialized: permissions=&{Allow:[...]}`,
48
+ * which is the fastest way to confirm which file won.
49
+ */
50
+ export declare function agySettingsRoot(): string;
51
+ /** True when the entry was written by this plugin. */
52
+ export declare function isOwnedEntry(value: unknown): boolean;
53
+ /**
54
+ * The single allow rule this plugin needs: the server-scoped wildcard. AGY
55
+ * matches `mcp(<server>/*)` (the syntax its own binary documents, e.g.
56
+ * `mcp(chrome_devtools/*)`), so every DSH tool the bridge reports is granted
57
+ * without enumerating names — and a DSH tool added later needs no config edit.
58
+ */
59
+ export declare function agyAllowRule(_toolName?: string): string;
60
+ /**
61
+ * Write (or replace) this plugin's AGY MCP server entry. Returns the config
62
+ * path so callers can report it. Foreign entries are preserved untouched.
63
+ */
64
+ export declare function registerAgyMcpServer(entry: AgyMcpEntry, options?: AgyMcpRegistrationOptions): Promise<AgyMcpConfigFile>;
65
+ /**
66
+ * Remove this plugin's AGY MCP server entry, leaving every other entry alone.
67
+ * Returns true when an entry was actually removed.
68
+ */
69
+ export declare function unregisterAgyMcpServer(options?: AgyMcpRegistrationOptions): Promise<boolean>;
70
+ //# sourceMappingURL=agy-registration.d.ts.map
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Register the DSH tool bridge as an MCP server inside the AGY CLI.
3
+ *
4
+ * AGY keeps its MCP server table in its own config root
5
+ * (`~/.gemini/antigravity-cli/gemini-root-config/`, see {@link agyConfigRoot})
6
+ * and its settings one level above that root (see {@link agySettingsRoot}), so
7
+ * this is the one place where the plugin writes outside the DSH profile.
8
+ * Three rules keep that honest:
9
+ *
10
+ * 1. every write is attributed — the entry is named and tagged by this plugin,
11
+ * so it can be identified again on the next start;
12
+ * 2. registration is bound to the plugin lifecycle: the entry is written when
13
+ * the provider becomes active and removed when it stops, so uninstalling or
14
+ * disabling the plugin does not leave a live endpoint behind;
15
+ * 3. an allow rule is written alongside the entry, because headless AGY cannot
16
+ * prompt for the `mcp` permission and auto-denies every MCP call without one
17
+ * (stderr: `a tool required the "mcp" permission that headless mode cannot
18
+ * prompt for, so it was auto-denied`). The rule is the server-scoped
19
+ * wildcard `mcp(<server>/*)` — the syntax AGY itself documents for its
20
+ * bundled servers (`mcp(chrome_devtools/*)` appears in the CLI binary).
21
+ * `mcp(<server>)` alone does not match, and the bare `mcp(*)` would hand
22
+ * AGY every MCP server on the machine. Enumerating one rule per tool was
23
+ * the original approach and is what forced this plugin to rewrite the file
24
+ * every time DSH changed its tool set; the wildcard makes DSH-side grants
25
+ * the only control point.
26
+ *
27
+ * The edit is a read-modify-write of the AGY config with a backup, matching
28
+ * what the AGY CLI itself does for `agy mcp add`. The AGY CLI is used only to
29
+ * locate the config root, never to mutate it: `agy mcp add` cannot express the
30
+ * "replace my previous entry" semantics this needs, and it has no equivalent
31
+ * for the permission rules.
32
+ */
33
+ import { existsSync } from 'node:fs';
34
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
35
+ import { dirname, join } from 'node:path';
36
+ import { homedir } from 'node:os';
37
+ /** The MCP server name AGY shows for this bridge. */
38
+ export const AGY_MCP_SERVER_NAME = 'dsh';
39
+ /** Marks an entry as owned by this plugin so cleanup never touches foreign servers. */
40
+ const OWNER_TAG = 'dsh-agy-cli';
41
+ /**
42
+ * The directory AGY reads `mcp_config.json` and `agents/` from.
43
+ *
44
+ * It is the `gemini-root-config` child of the CLI data directory. `agy mcp
45
+ * list`, `agy agents`, and `agy mcp add` all read and write this nested root —
46
+ * verified by adding a server with the CLI and watching only the nested copy
47
+ * change on disk.
48
+ *
49
+ * `HOME`/`USERPROFILE` overrides do not relocate this either: the CLI resolves
50
+ * it through the Windows known-folder API, so tests must pass `configRoot`
51
+ * rather than trying to sandbox the process environment.
52
+ */
53
+ export function agyConfigRoot() {
54
+ const explicit = process.env.DSH_AGY_CONFIG_ROOT;
55
+ if (typeof explicit === 'string' && explicit.trim().length > 0)
56
+ return explicit.trim();
57
+ return join(homedir(), '.gemini', 'antigravity-cli', 'gemini-root-config');
58
+ }
59
+ /**
60
+ * The directory AGY reads `settings.json` from.
61
+ *
62
+ * Settings live one level *above* the MCP config, not beside it. Measured with
63
+ * a signed probe rule: a rule added to the nested `settings.json` never reaches
64
+ * the `Allow:[...]` table AGY logs at startup, while the same rule added to the
65
+ * outer file shows up immediately. Only the outer file is ever loaded.
66
+ *
67
+ * Getting this wrong is silent in the worst way: the nested file looks correct
68
+ * on disk, and every MCP tool call is then auto-denied in headless mode. AGY
69
+ * logs the real table as `CLI settings initialized: permissions=&{Allow:[...]}`,
70
+ * which is the fastest way to confirm which file won.
71
+ */
72
+ export function agySettingsRoot() {
73
+ const explicit = process.env.DSH_AGY_SETTINGS_ROOT;
74
+ if (typeof explicit === 'string' && explicit.trim().length > 0)
75
+ return explicit.trim();
76
+ return join(homedir(), '.gemini', 'antigravity-cli');
77
+ }
78
+ function isRecord(value) {
79
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
80
+ }
81
+ function mcpConfigPath(options) {
82
+ return join(options.configRoot ?? agyConfigRoot(), 'mcp_config.json');
83
+ }
84
+ function settingsPath(options) {
85
+ return join(options.settingsRoot ?? options.configRoot ?? agyConfigRoot(), 'settings.json');
86
+ }
87
+ async function readConfig(path) {
88
+ if (!existsSync(path))
89
+ return { mcpServers: {} };
90
+ try {
91
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
92
+ if (!isRecord(parsed))
93
+ return { mcpServers: {} };
94
+ const servers = parsed.mcpServers;
95
+ return { ...parsed, mcpServers: isRecord(servers) ? { ...servers } : {} };
96
+ }
97
+ catch (error) {
98
+ // Never overwrite a config that exists but cannot be parsed: that file is
99
+ // the user's, and silently replacing it would drop their other servers.
100
+ throw new Error(`AGY MCP config at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
101
+ }
102
+ }
103
+ /** True when the entry was written by this plugin. */
104
+ export function isOwnedEntry(value) {
105
+ if (!isRecord(value))
106
+ return false;
107
+ const headers = value.headers;
108
+ if (!isRecord(headers))
109
+ return false;
110
+ return headers['x-dsh-owner'] === OWNER_TAG;
111
+ }
112
+ function entryFor(entry) {
113
+ return {
114
+ type: 'http',
115
+ url: entry.url,
116
+ headers: {
117
+ 'x-dsh-owner': OWNER_TAG,
118
+ authorization: `Bearer ${entry.token}`,
119
+ },
120
+ };
121
+ }
122
+ async function writeConfig(path, root) {
123
+ await mkdir(dirname(path), { recursive: true });
124
+ const backup = `${path}.dsh-agy-cli.bak`;
125
+ if (existsSync(path) && !existsSync(backup)) {
126
+ await writeFile(backup, await readFile(path), { encoding: 'utf8' });
127
+ }
128
+ const temporary = `${path}.dsh-agy-cli.tmp`;
129
+ await writeFile(temporary, `${JSON.stringify(root, null, 2)}\n`, { encoding: 'utf8' });
130
+ await rename(temporary, path);
131
+ }
132
+ /**
133
+ * The single allow rule this plugin needs: the server-scoped wildcard. AGY
134
+ * matches `mcp(<server>/*)` (the syntax its own binary documents, e.g.
135
+ * `mcp(chrome_devtools/*)`), so every DSH tool the bridge reports is granted
136
+ * without enumerating names — and a DSH tool added later needs no config edit.
137
+ */
138
+ export function agyAllowRule(_toolName) {
139
+ return `mcp(${AGY_MCP_SERVER_NAME}/*)`;
140
+ }
141
+ function isAllowRuleForThisBridge(rule) {
142
+ return rule.startsWith(`mcp(${AGY_MCP_SERVER_NAME}/`);
143
+ }
144
+ /**
145
+ * Read the AGY settings file. A file that exists but cannot be parsed is an
146
+ * error rather than an empty document: overwriting it would drop the user's
147
+ * trusted workspaces and unrelated permission rules.
148
+ */
149
+ async function readSettings(path) {
150
+ if (!existsSync(path))
151
+ return {};
152
+ try {
153
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
154
+ return isRecord(parsed) ? { ...parsed } : {};
155
+ }
156
+ catch (error) {
157
+ throw new Error(`AGY settings at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
158
+ }
159
+ }
160
+ /**
161
+ * Add this bridge's allow rules to the AGY settings, replacing any rules left
162
+ * by a previous run and preserving every other entry verbatim.
163
+ */
164
+ async function writeAllowRules(rules, options) {
165
+ const path = settingsPath(options);
166
+ const root = await readSettings(path);
167
+ const permissions = isRecord(root.permissions) ? { ...root.permissions } : {};
168
+ const existing = Array.isArray(permissions.allow) ? permissions.allow : [];
169
+ const foreign = existing.filter((rule) => typeof rule === 'string' && !isAllowRuleForThisBridge(rule));
170
+ if (rules.length === 0 && foreign.length === existing.length)
171
+ return path;
172
+ permissions.allow = [...foreign, ...rules];
173
+ await writeConfig(path, { ...root, permissions });
174
+ options.logger?.(`allowed ${rules.length} AGY MCP tool(s) in ${path}`);
175
+ return path;
176
+ }
177
+ /** Drop this bridge's allow rules, leaving foreign rules untouched. */
178
+ async function clearAllowRules(options) {
179
+ const path = settingsPath(options);
180
+ if (!existsSync(path))
181
+ return false;
182
+ const root = await readSettings(path);
183
+ if (!isRecord(root.permissions))
184
+ return false;
185
+ const permissions = { ...root.permissions };
186
+ const existing = Array.isArray(permissions.allow) ? permissions.allow : [];
187
+ const foreign = existing.filter((rule) => typeof rule === 'string' && !isAllowRuleForThisBridge(rule));
188
+ if (foreign.length === existing.length)
189
+ return false;
190
+ if (foreign.length === 0)
191
+ delete permissions.allow;
192
+ else
193
+ permissions.allow = foreign;
194
+ if (Object.keys(permissions).length === 0)
195
+ delete root.permissions;
196
+ else
197
+ root.permissions = permissions;
198
+ await writeConfig(path, root);
199
+ options.logger?.(`removed this plugin's AGY allow rules from ${path}`);
200
+ return true;
201
+ }
202
+ /**
203
+ * Write (or replace) this plugin's AGY MCP server entry. Returns the config
204
+ * path so callers can report it. Foreign entries are preserved untouched.
205
+ */
206
+ export async function registerAgyMcpServer(entry, options = {}) {
207
+ const path = mcpConfigPath(options);
208
+ const root = await readConfig(path);
209
+ const servers = root.mcpServers;
210
+ servers[AGY_MCP_SERVER_NAME] = entryFor(entry);
211
+ await writeConfig(path, { ...root, mcpServers: servers });
212
+ options.logger?.(`registered AGY MCP server "${AGY_MCP_SERVER_NAME}" at ${path}`);
213
+ await writeAllowRules([agyAllowRule()], options);
214
+ return { path, root };
215
+ }
216
+ /**
217
+ * Remove this plugin's AGY MCP server entry, leaving every other entry alone.
218
+ * Returns true when an entry was actually removed.
219
+ */
220
+ export async function unregisterAgyMcpServer(options = {}) {
221
+ const path = mcpConfigPath(options);
222
+ if (!existsSync(path))
223
+ return false;
224
+ const root = await readConfig(path);
225
+ const servers = root.mcpServers;
226
+ const current = servers[AGY_MCP_SERVER_NAME];
227
+ if (current === undefined) {
228
+ // The entry can be gone while the allow rules survive a crashed session;
229
+ // clearing them is still this plugin's job.
230
+ await clearAllowRules(options).catch(() => false);
231
+ return false;
232
+ }
233
+ // Only remove an entry this plugin wrote; a user-managed server that happens
234
+ // to be named "dsh" is theirs to keep.
235
+ if (!isOwnedEntry(current)) {
236
+ options.logger?.(`AGY MCP server "${AGY_MCP_SERVER_NAME}" is not owned by this plugin; leaving it in place`);
237
+ return false;
238
+ }
239
+ delete servers[AGY_MCP_SERVER_NAME];
240
+ await writeConfig(path, { ...root, mcpServers: servers });
241
+ options.logger?.(`removed AGY MCP server "${AGY_MCP_SERVER_NAME}" from ${path}`);
242
+ await clearAllowRules(options).catch(() => false);
243
+ return true;
244
+ }
245
+ //# sourceMappingURL=agy-registration.js.map
@@ -0,0 +1,73 @@
1
+ import { type DshWebServerLike, type McpEndpoint } from './endpoint.js';
2
+ export type { DshWebServerLike, McpEndpoint };
3
+ import { agyConfigRoot, agySettingsRoot, AGY_MCP_SERVER_NAME } from './agy-registration.js';
4
+ import { type DshToolRegistryLike } from './tool-projection.js';
5
+ /** The subset of the DSH tool registry this bridge uses. */
6
+ export interface DshToolRuntimeLike extends DshToolRegistryLike {
7
+ execute(execution: {
8
+ readonly callId: unknown;
9
+ readonly name: string;
10
+ readonly arguments: unknown;
11
+ readonly agent?: unknown;
12
+ readonly signal: AbortSignal;
13
+ }): Promise<DshToolExecutionResultLike>;
14
+ }
15
+ export interface DshToolExecutionResultLike {
16
+ readonly isError: boolean;
17
+ readonly error?: {
18
+ readonly message?: unknown;
19
+ } | undefined;
20
+ readonly value?: unknown;
21
+ readonly content?: readonly unknown[];
22
+ }
23
+ /** The host service that resolves a session id to its live Agent. */
24
+ export interface DshSessionControllerLike {
25
+ resolveAgent(sessionId: unknown): Promise<unknown>;
26
+ }
27
+ export interface DshMcpBridgeOptions {
28
+ readonly webServer: DshWebServerLike;
29
+ readonly tools: DshToolRuntimeLike;
30
+ readonly sessionController?: DshSessionControllerLike | undefined;
31
+ /**
32
+ * Late-bound `sessionController` lookup.
33
+ *
34
+ * The bridge starts while this plugin loads, and the Session controller is
35
+ * mounted by a different bundle: reading the service once at start can pin
36
+ * `undefined` for the whole host lifetime, which silently degrades every
37
+ * scoped query to the global tool view (the file and shell tools live on the
38
+ * agent plane). Resolving per turn removes the load-order dependency.
39
+ */
40
+ readonly sessionControllerLookup?: (() => DshSessionControllerLike | undefined) | undefined;
41
+ /** Optional allowlist of DSH tool names exposed to AGY. */
42
+ readonly toolAllowlist?: readonly string[] | undefined;
43
+ readonly serverVersion: string;
44
+ /** Overrides the AGY MCP-config root; tests pass a temporary directory. */
45
+ readonly agyConfigRoot?: string | undefined;
46
+ /**
47
+ * Overrides the AGY settings root. `agySettingsRoot()` is the production
48
+ * default (the outer directory); this knob only exists so a test can
49
+ * keep both files in one temporary directory.
50
+ */
51
+ readonly agySettingsRoot?: string | undefined;
52
+ readonly logger?: (message: string) => void;
53
+ }
54
+ export interface DshMcpBridge {
55
+ /** The bridge is live: endpoint mounted and AGY config entry written. */
56
+ readonly active: boolean;
57
+ /** Why the bridge is not active, when it is not. */
58
+ readonly inactiveReason: string | undefined;
59
+ readonly endpoint: McpEndpoint | undefined;
60
+ /** URL AGY reaches the bridge on; undefined until the bridge is live. */
61
+ readonly url: string | undefined;
62
+ /** Point the bridge at the DSH session owning the next AGY turn. */
63
+ readonly setSession: (sessionId: string) => void;
64
+ readonly dispose: () => Promise<void>;
65
+ }
66
+ /**
67
+ * Bring the bridge up. Every failure mode resolves to `active: false` with a
68
+ * reason instead of throwing, so the provider can fall back to the prompt
69
+ * contract path when the carrier or the tool registry is not available.
70
+ */
71
+ export declare function startDshMcpBridge(options: DshMcpBridgeOptions): Promise<DshMcpBridge>;
72
+ export { AGY_MCP_SERVER_NAME, agyConfigRoot, agySettingsRoot };
73
+ //# sourceMappingURL=bridge.d.ts.map