@oh-my-pi/pi-coding-agent 16.1.19 → 16.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/cli.js +3616 -3592
- package/dist/types/cli/gallery-cli.d.ts +6 -0
- package/dist/types/commands/gallery.d.ts +1 -1
- package/dist/types/config/service-tier.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +36 -33
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/task/parallel.d.ts +17 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/package.json +13 -13
- package/src/cli/gallery-cli.ts +31 -2
- package/src/cli/gallery-fixtures/agentic.ts +13 -4
- package/src/commands/gallery.ts +11 -3
- package/src/config/service-tier.ts +87 -0
- package/src/config/settings-schema.ts +48 -23
- package/src/eval/agent-bridge.ts +2 -0
- package/src/main.ts +1 -0
- package/src/mcp/transports/stdio.ts +5 -0
- package/src/modes/controllers/input-controller.ts +143 -66
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +72 -15
- package/src/session/session-history-format.ts +21 -4
- package/src/task/executor.ts +79 -2
- package/src/task/index.ts +11 -6
- package/src/task/parallel.ts +59 -7
- package/src/tools/index.ts +3 -1
- package/src/tools/irc.ts +16 -2
- package/src/utils/shell-snapshot-fn-env.sh +60 -0
- package/src/utils/shell-snapshot.ts +77 -20
package/src/task/executor.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
9
9
|
import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
|
|
10
|
-
import type { Api, Model, Usage } from "@oh-my-pi/pi-ai";
|
|
10
|
+
import type { Api, Model, ServiceTier, Usage } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import type { Rule } from "../capability/rule";
|
|
13
13
|
import { ModelRegistry } from "../config/model-registry";
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveModelOverrideWithAuthFallback,
|
|
19
19
|
} from "../config/model-resolver";
|
|
20
20
|
import type { PromptTemplate } from "../config/prompt-templates";
|
|
21
|
+
import { resolveSubagentServiceTier } from "../config/service-tier";
|
|
21
22
|
import { Settings } from "../config/settings";
|
|
22
23
|
import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
|
|
23
24
|
import type { ToolPathWithSource } from "../extensibility/custom-tools";
|
|
@@ -50,12 +51,12 @@ import {
|
|
|
50
51
|
type OutputValidator,
|
|
51
52
|
summarizeValidationFailure,
|
|
52
53
|
} from "../tools/output-schema-validator";
|
|
53
|
-
|
|
54
54
|
import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
|
|
55
55
|
import { ToolAbortError } from "../tools/tool-errors";
|
|
56
56
|
import type { EventBus } from "../utils/event-bus";
|
|
57
57
|
import { buildNamedToolChoice } from "../utils/tool-choice";
|
|
58
58
|
import type { WorkspaceTree } from "../workspace-tree";
|
|
59
|
+
import { Semaphore } from "./parallel";
|
|
59
60
|
import { subprocessToolRegistry } from "./subprocess-tool-registry";
|
|
60
61
|
import {
|
|
61
62
|
type AgentDefinition,
|
|
@@ -194,6 +195,51 @@ function installSubagentRetryFallbackChain(args: {
|
|
|
194
195
|
return role;
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
|
|
199
|
+
"ollama-cloud": "providers.ollama-cloud.maxConcurrency",
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
interface ProviderSemaphoreEntry {
|
|
203
|
+
limit: number;
|
|
204
|
+
semaphore: Semaphore;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve the configured concurrency ceiling for a provider, or `undefined`
|
|
211
|
+
* when the provider has no cap concept at all. A configured value `<= 0` means
|
|
212
|
+
* "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
|
|
213
|
+
* holds a slot and a later finite resize counts work started while unlimited.
|
|
214
|
+
*/
|
|
215
|
+
function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
|
|
216
|
+
const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
|
|
217
|
+
if (!settingPath) return undefined;
|
|
218
|
+
const raw = settings.get(settingPath);
|
|
219
|
+
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
220
|
+
return limit > 0 ? limit : Number.POSITIVE_INFINITY;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
|
|
224
|
+
const limit = getProviderConcurrencyLimit(settings, provider);
|
|
225
|
+
if (limit === undefined) return undefined;
|
|
226
|
+
// Always hand out (and acquire on) the single shared limiter, even when
|
|
227
|
+
// unlimited (Infinity). Resizing it in place — rather than replacing it —
|
|
228
|
+
// keeps every in-flight slot counted, so a runtime or mixed limit change can
|
|
229
|
+
// never push concurrency past the cap (issue #3464 review feedback).
|
|
230
|
+
const existing = providerSemaphores.get(provider);
|
|
231
|
+
if (existing) {
|
|
232
|
+
if (existing.limit !== limit) {
|
|
233
|
+
existing.limit = limit;
|
|
234
|
+
existing.semaphore.resize(limit);
|
|
235
|
+
}
|
|
236
|
+
return existing.semaphore;
|
|
237
|
+
}
|
|
238
|
+
const semaphore = new Semaphore(limit);
|
|
239
|
+
providerSemaphores.set(provider, { limit, semaphore });
|
|
240
|
+
return semaphore;
|
|
241
|
+
}
|
|
242
|
+
|
|
197
243
|
function renderIrcPeerRoster(selfId: string): string {
|
|
198
244
|
const peers = AgentRegistry.global()
|
|
199
245
|
.list()
|
|
@@ -339,6 +385,13 @@ export interface ExecutorOptions {
|
|
|
339
385
|
authStorage?: AuthStorage;
|
|
340
386
|
modelRegistry?: ModelRegistry;
|
|
341
387
|
settings?: Settings;
|
|
388
|
+
/**
|
|
389
|
+
* Parent session's live effective service tier, the source of truth for a
|
|
390
|
+
* subagent whose `serviceTierSubagent` is `"inherit"`. `null` = the parent
|
|
391
|
+
* explicitly has no tier (e.g. `/fast off`); omitted = no live session, so
|
|
392
|
+
* inherit falls back to the configured `serviceTier` setting.
|
|
393
|
+
*/
|
|
394
|
+
parentServiceTier?: ServiceTier | null;
|
|
342
395
|
/** Override local:// protocol options so subagent shares parent's local:// root */
|
|
343
396
|
localProtocolOptions?: LocalProtocolOptions;
|
|
344
397
|
/**
|
|
@@ -732,11 +785,21 @@ export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
|
|
|
732
785
|
export function createSubagentSettings(
|
|
733
786
|
baseSettings: Settings,
|
|
734
787
|
overrides?: Partial<Record<SettingPath, unknown>>,
|
|
788
|
+
inheritedServiceTier?: ServiceTier | null,
|
|
735
789
|
): Settings {
|
|
736
790
|
const snapshot: Partial<Record<SettingPath, unknown>> = {};
|
|
737
791
|
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
|
|
738
792
|
snapshot[key] = baseSettings.get(key);
|
|
739
793
|
}
|
|
794
|
+
// Resolve the subagent's service tier from `serviceTierSubagent` ("inherit" =
|
|
795
|
+
// match the parent's live tier when a live session supplied one, else the
|
|
796
|
+
// configured `serviceTier`). The result is stamped back onto the snapshot so
|
|
797
|
+
// createAgentSession's `settings.get("serviceTier")` read picks it up.
|
|
798
|
+
snapshot.serviceTier = resolveSubagentServiceTier(
|
|
799
|
+
baseSettings.get("serviceTierSubagent"),
|
|
800
|
+
baseSettings.get("serviceTier"),
|
|
801
|
+
inheritedServiceTier,
|
|
802
|
+
);
|
|
740
803
|
return Settings.isolated({
|
|
741
804
|
...snapshot,
|
|
742
805
|
"async.enabled": false,
|
|
@@ -1747,6 +1810,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1747
1810
|
const subagentSettings = createSubagentSettings(
|
|
1748
1811
|
settings,
|
|
1749
1812
|
agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
|
|
1813
|
+
options.parentServiceTier,
|
|
1750
1814
|
);
|
|
1751
1815
|
const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
|
|
1752
1816
|
// Tailored specialist identity for this spawn. `subagentRole` is the full
|
|
@@ -1889,6 +1953,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1889
1953
|
let sessionOpenedAt: number | undefined;
|
|
1890
1954
|
let sessionCreatedAt: number | undefined;
|
|
1891
1955
|
let readyAt: number | undefined;
|
|
1956
|
+
let providerSemaphore: Semaphore | undefined;
|
|
1957
|
+
let providerSemaphoreAcquired = false;
|
|
1892
1958
|
|
|
1893
1959
|
try {
|
|
1894
1960
|
checkAbort();
|
|
@@ -1957,6 +2023,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1957
2023
|
? resolvedThinkingLevel
|
|
1958
2024
|
: (thinkingLevel ?? resolvedThinkingLevel);
|
|
1959
2025
|
resolvedAt = performance.now();
|
|
2026
|
+
if (model) {
|
|
2027
|
+
providerSemaphore = getProviderSemaphore(settings, model.provider);
|
|
2028
|
+
if (providerSemaphore) {
|
|
2029
|
+
await providerSemaphore.acquire(abortSignal);
|
|
2030
|
+
providerSemaphoreAcquired = true;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
1960
2033
|
|
|
1961
2034
|
const effectiveCwd = worktree ?? cwd;
|
|
1962
2035
|
const sessionManager = sessionFile
|
|
@@ -2247,6 +2320,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2247
2320
|
}
|
|
2248
2321
|
if (exitCode === 0) exitCode = 1;
|
|
2249
2322
|
}
|
|
2323
|
+
if (providerSemaphoreAcquired) {
|
|
2324
|
+
providerSemaphore?.release();
|
|
2325
|
+
providerSemaphoreAcquired = false;
|
|
2326
|
+
}
|
|
2250
2327
|
sessionAbortController.abort();
|
|
2251
2328
|
if (unsubscribe) {
|
|
2252
2329
|
try {
|
package/src/task/index.ts
CHANGED
|
@@ -798,13 +798,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
798
798
|
onSettled?.(true);
|
|
799
799
|
throw new Error("Aborted before execution");
|
|
800
800
|
}
|
|
801
|
-
markRunning();
|
|
802
|
-
progress.status = "running";
|
|
803
|
-
await reportProgress(
|
|
804
|
-
`Running background task ${agentId}...`,
|
|
805
|
-
buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
|
|
806
|
-
);
|
|
807
801
|
try {
|
|
802
|
+
markRunning();
|
|
803
|
+
progress.status = "running";
|
|
804
|
+
await reportProgress(
|
|
805
|
+
`Running background task ${agentId}...`,
|
|
806
|
+
buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
|
|
807
|
+
);
|
|
808
808
|
const result = await this.#executeSync(
|
|
809
809
|
toolCallId,
|
|
810
810
|
spawnParams,
|
|
@@ -1296,6 +1296,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1296
1296
|
parentTelemetry: this.session.getTelemetry?.(),
|
|
1297
1297
|
parentEvalSessionId,
|
|
1298
1298
|
parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
|
|
1299
|
+
// Live source of truth for `serviceTierSubagent: inherit`. When the
|
|
1300
|
+
// session exposes a tier accessor, pass tier-or-null (null = explicit
|
|
1301
|
+
// none, e.g. /fast off); otherwise leave undefined so inherit falls
|
|
1302
|
+
// back to the configured serviceTier setting.
|
|
1303
|
+
parentServiceTier: this.session.getServiceTier ? (this.session.getServiceTier() ?? null) : undefined,
|
|
1299
1304
|
};
|
|
1300
1305
|
|
|
1301
1306
|
const runTask = async (): Promise<SingleResult> => {
|
package/src/task/parallel.ts
CHANGED
|
@@ -100,22 +100,74 @@ export class Semaphore {
|
|
|
100
100
|
this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Resolves when a slot is available. Pass an `AbortSignal` so callers that
|
|
105
|
+
* stop waiting (parent task cancelled, wall-clock budget elapsed) also stop
|
|
106
|
+
* occupying a queue slot — otherwise a later `release()` would resolve the
|
|
107
|
+
* abandoned waiter, permanently shrinking effective concurrency for the
|
|
108
|
+
* remaining lifetime of the process (issue #3464 review feedback).
|
|
109
|
+
*/
|
|
110
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
111
|
+
if (signal?.aborted) {
|
|
112
|
+
throw semaphoreAbortReason(signal);
|
|
113
|
+
}
|
|
104
114
|
if (this.#current < this.#max) {
|
|
105
115
|
this.#current++;
|
|
106
116
|
return;
|
|
107
117
|
}
|
|
108
|
-
const { promise, resolve } = Promise.withResolvers<void>();
|
|
109
|
-
this.#queue
|
|
118
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
119
|
+
const queue = this.#queue;
|
|
120
|
+
let waiter: () => void = resolve;
|
|
121
|
+
if (signal) {
|
|
122
|
+
const onAbort = () => {
|
|
123
|
+
const index = queue.indexOf(waiter);
|
|
124
|
+
if (index >= 0) queue.splice(index, 1);
|
|
125
|
+
reject(semaphoreAbortReason(signal));
|
|
126
|
+
};
|
|
127
|
+
waiter = () => {
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
resolve();
|
|
130
|
+
};
|
|
131
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
}
|
|
133
|
+
queue.push(waiter);
|
|
110
134
|
return promise;
|
|
111
135
|
}
|
|
112
136
|
|
|
113
137
|
release(): void {
|
|
114
|
-
|
|
115
|
-
if (
|
|
138
|
+
if (this.#current > 0) this.#current--;
|
|
139
|
+
// Admit the next waiter only if we are under the (possibly just-lowered) ceiling.
|
|
140
|
+
if (this.#current < this.#max) {
|
|
141
|
+
const next = this.#queue.shift();
|
|
142
|
+
if (next) {
|
|
143
|
+
this.#current++;
|
|
144
|
+
next();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Adjust the maximum concurrency in place. Raising the ceiling immediately
|
|
151
|
+
* admits queued waiters that now fit; lowering it lets in-flight holders
|
|
152
|
+
* drain naturally (new acquires keep blocking until `#current` falls below
|
|
153
|
+
* the new max). Resizing the single shared instance — instead of replacing
|
|
154
|
+
* it — keeps in-flight slots counted, so a runtime or mixed limit change can
|
|
155
|
+
* never push concurrency past the cap (issue #3464 review feedback).
|
|
156
|
+
*/
|
|
157
|
+
resize(max: number): void {
|
|
158
|
+
const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
|
|
159
|
+
this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
|
|
160
|
+
while (this.#current < this.#max) {
|
|
161
|
+
const next = this.#queue.shift();
|
|
162
|
+
if (!next) break;
|
|
163
|
+
this.#current++;
|
|
116
164
|
next();
|
|
117
|
-
} else {
|
|
118
|
-
this.#current--;
|
|
119
165
|
}
|
|
120
166
|
}
|
|
121
167
|
}
|
|
168
|
+
|
|
169
|
+
function semaphoreAbortReason(signal: AbortSignal): unknown {
|
|
170
|
+
const reason = signal.reason;
|
|
171
|
+
if (reason !== undefined) return reason;
|
|
172
|
+
return new Error("Semaphore acquire aborted");
|
|
173
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
2
2
|
import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
3
|
-
import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import type { FetchImpl, ImageContent, Model, ServiceTier, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import type { AsyncJobManager } from "../async/job-manager";
|
|
6
6
|
import type { Rule } from "../capability/rule";
|
|
@@ -240,6 +240,8 @@ export interface ToolSession {
|
|
|
240
240
|
getActiveModelString?: () => string | undefined;
|
|
241
241
|
/** Get the current session model object (provider/api capabilities), regardless of how it was chosen. */
|
|
242
242
|
getActiveModel?: () => Model | undefined;
|
|
243
|
+
/** Get the session's live effective service tier (undefined = none). Source of truth for subagent `serviceTierSubagent: inherit`. */
|
|
244
|
+
getServiceTier?: () => ServiceTier | undefined;
|
|
243
245
|
/** Auth storage for passing to subagents (avoids re-discovery) */
|
|
244
246
|
authStorage?: import("../session/auth-storage").AuthStorage;
|
|
245
247
|
/** Model registry for passing to subagents (avoids re-discovery) */
|
package/src/tools/irc.ts
CHANGED
|
@@ -514,6 +514,18 @@ function callMeta(args: IrcRenderArgs | undefined): string[] {
|
|
|
514
514
|
return meta;
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
function renderErrorResult(
|
|
518
|
+
result: { content: Array<{ type: string; text?: string }> },
|
|
519
|
+
args: IrcRenderArgs | undefined,
|
|
520
|
+
theme: Theme,
|
|
521
|
+
): string[] {
|
|
522
|
+
const text = textContent(result) || "IRC call failed.";
|
|
523
|
+
return [
|
|
524
|
+
renderStatusLine({ icon: "error", title: callTitle(args, theme), meta: callMeta(args) }, theme),
|
|
525
|
+
formatErrorDetail(text, theme),
|
|
526
|
+
];
|
|
527
|
+
}
|
|
528
|
+
|
|
517
529
|
/**
|
|
518
530
|
* Display-only transcript card for live IRC traffic: `irc:incoming` DMs
|
|
519
531
|
* delivered to this session, `irc:autoreply` side-channel replies sent on
|
|
@@ -743,9 +755,11 @@ function buildResultLines(
|
|
|
743
755
|
case "wait":
|
|
744
756
|
return renderWaitResult(result, details, args, expanded, theme);
|
|
745
757
|
case "inbox":
|
|
746
|
-
return
|
|
758
|
+
return result.isError
|
|
759
|
+
? renderErrorResult(result, args, theme)
|
|
760
|
+
: renderInboxResult(details, args, expanded, theme);
|
|
747
761
|
case "list":
|
|
748
|
-
return renderListResult(details, expanded, theme);
|
|
762
|
+
return result.isError ? renderErrorResult(result, args, theme) : renderListResult(details, expanded, theme);
|
|
749
763
|
default: {
|
|
750
764
|
const text = textContent(result) || (result.isError ? "IRC call failed." : "Done.");
|
|
751
765
|
return [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Helpers inlined into `generateSnapshotScript` (shell-snapshot.ts).
|
|
2
|
+
#
|
|
3
|
+
# Activation idioms like `mise activate` install a shell function whose body
|
|
4
|
+
# expands a sidecar env var (e.g. `mise() { command "$__MISE_EXE" "$@"; }`).
|
|
5
|
+
# The function survives `declare -f`/`typeset -f` capture, but the helper var
|
|
6
|
+
# is set on the rc-sourced shell and lost when only PATH gets re-exported.
|
|
7
|
+
# The replay shell then calls `command "" …` and dies with
|
|
8
|
+
# `command: command not found:` (issue #3470).
|
|
9
|
+
#
|
|
10
|
+
# `__omp_emit_referenced_exports` reads captured function bodies from stdin,
|
|
11
|
+
# extracts every `$VAR` / `${VAR…}` reference, and emits a single
|
|
12
|
+
# `export VAR='value'` line to stdout for each referenced var that
|
|
13
|
+
# (a) is currently set in this shell and (b) is not a shell-internal name.
|
|
14
|
+
# The script bodies are scanned, not interpreted — over-inclusion (refs inside
|
|
15
|
+
# comments / heredocs) is harmless because we only emit names that are set.
|
|
16
|
+
#
|
|
17
|
+
# Pure POSIX so the same helper works under bash and zsh.
|
|
18
|
+
|
|
19
|
+
# Wrap $1 in single quotes, escaping every embedded `'` as `'\''`.
|
|
20
|
+
__omp_sq_quote() {
|
|
21
|
+
__omp_qbuf=$1
|
|
22
|
+
__omp_qout=
|
|
23
|
+
__omp_sq=\'
|
|
24
|
+
while case "$__omp_qbuf" in *$__omp_sq*) true ;; *) false ;; esac; do
|
|
25
|
+
__omp_qout=$__omp_qout${__omp_qbuf%%$__omp_sq*}"'\\''"
|
|
26
|
+
__omp_qbuf=${__omp_qbuf#*$__omp_sq}
|
|
27
|
+
done
|
|
28
|
+
__omp_qout=$__omp_qout$__omp_qbuf
|
|
29
|
+
printf "'%s'" "$__omp_qout"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Emit `export NAME='value'` for $1 unless the name is a shell-internal we
|
|
33
|
+
# must never overwrite, a likely secret (token / key / password / credential
|
|
34
|
+
# patterns — kept conservative, since `__MISE_EXE` and `FOO_DIR` style helper
|
|
35
|
+
# vars never carry secrets), or the var is unset. POSIX `case` patterns are
|
|
36
|
+
# byte-exact so we list common uppercase variants; lowercase secret vars are
|
|
37
|
+
# rare and out of scope.
|
|
38
|
+
__omp_emit_export_for() {
|
|
39
|
+
case "$1" in
|
|
40
|
+
_|PATH|HOME|USER|LOGNAME|PWD|OLDPWD|SHELL|SHLVL|TERM|TERMINFO|TERMCAP|IFS|TMPDIR|TMOUT|LANG|RANDOM|LINENO|SECONDS|FUNCNAME|HISTFILE|HISTSIZE|HISTFILESIZE|HISTCMD|PS1|PS2|PS3|PS4|UID|EUID|GROUPS|HOSTNAME|HOSTTYPE|OSTYPE|MACHTYPE|PIPESTATUS|BASH|ZSH|argv|PROMPT|RPROMPT|RPS1|RPS2|status|pipestatus|COLUMNS|LINES|COLORTERM|FUNCNEST) return ;;
|
|
41
|
+
LC_*|BASH_*|ZSH_*) return ;;
|
|
42
|
+
# Common secret-name patterns — never materialise these into the
|
|
43
|
+
# snapshot file even though it's now created 0600 (defence in depth
|
|
44
|
+
# against the file ending up in a backup, tarball, or NFS share).
|
|
45
|
+
*TOKEN*|*SECRET*|*PASSWORD*|*PASSWD*|*API_KEY*|*PRIVATE_KEY*|*ACCESS_KEY*|*CREDENTIAL*|*SESSION_KEY*) return ;;
|
|
46
|
+
esac
|
|
47
|
+
eval "[ \"\${$1+x}\" = x ]" 2>/dev/null || return
|
|
48
|
+
eval "__omp_xv=\"\${$1}\"" 2>/dev/null || return
|
|
49
|
+
printf 'export %s=%s\n' "$1" "$(__omp_sq_quote "$__omp_xv")"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Read function bodies on stdin, emit dedup'd export lines on stdout.
|
|
53
|
+
__omp_emit_referenced_exports() {
|
|
54
|
+
grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*' \
|
|
55
|
+
| sed -E 's/^\$\{?//' \
|
|
56
|
+
| sort -u \
|
|
57
|
+
| while IFS= read -r __omp_name; do
|
|
58
|
+
__omp_emit_export_for "$__omp_name"
|
|
59
|
+
done
|
|
60
|
+
}
|
|
@@ -9,6 +9,7 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
12
|
+
import fnEnvHelper from "./shell-snapshot-fn-env.sh" with { type: "text" };
|
|
12
13
|
|
|
13
14
|
const cachedSnapshotPaths = new Map<string, string>();
|
|
14
15
|
const SNAPSHOT_TIMEOUT_MS = 2_000;
|
|
@@ -83,9 +84,13 @@ function sanitizeSnapshotEnv(env: Record<string, string | undefined>): Record<st
|
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* Get the user's shell config file path.
|
|
87
|
+
*
|
|
88
|
+
* Honours `env.HOME` when present so a caller can target a sandboxed/test
|
|
89
|
+
* home, falling back to `os.homedir()` (which Bun resolves once at startup
|
|
90
|
+
* and caches — `process.env.HOME` overrides don't affect it).
|
|
86
91
|
*/
|
|
87
|
-
function getShellConfigFile(shell: string): string {
|
|
88
|
-
const home = os.homedir();
|
|
92
|
+
function getShellConfigFile(shell: string, env: Record<string, string | undefined>): string {
|
|
93
|
+
const home = env.HOME || os.homedir();
|
|
89
94
|
if (shell.includes("zsh")) return path.join(home, ".zshrc");
|
|
90
95
|
if (shell.includes("bash")) return path.join(home, ".bashrc");
|
|
91
96
|
return path.join(home, ".profile");
|
|
@@ -105,26 +110,22 @@ function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: str
|
|
|
105
110
|
// Escape the snapshot path for shell
|
|
106
111
|
const escapedPath = snapshotPath.replace(/'/g, "'\\''");
|
|
107
112
|
|
|
108
|
-
// Function extraction differs between bash and zsh
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
// Function extraction differs between bash and zsh. Each form prints function
|
|
114
|
+
// bodies on stdout so we can both persist them AND scan their bodies for
|
|
115
|
+
// referenced env vars (issue #3470).
|
|
116
|
+
const functionExtractor = isZsh
|
|
117
|
+
? `# Force autoload all functions first
|
|
113
118
|
typeset -f > /dev/null 2>&1
|
|
114
119
|
# Get user function names - filter system/private ones
|
|
115
120
|
typeset +f 2>/dev/null | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do
|
|
116
|
-
typeset -f "$func"
|
|
117
|
-
done
|
|
118
|
-
|
|
119
|
-
: `
|
|
120
|
-
echo "# Functions" >> "$SNAPSHOT_FILE"
|
|
121
|
-
# Force autoload all functions first
|
|
121
|
+
typeset -f "$func" 2>/dev/null
|
|
122
|
+
done`
|
|
123
|
+
: `# Force autoload all functions first
|
|
122
124
|
declare -f > /dev/null 2>&1
|
|
123
125
|
# Get user function names - filter system/private ones
|
|
124
126
|
declare -F 2>/dev/null | cut -d' ' -f3 | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do
|
|
125
|
-
declare -f "$func"
|
|
126
|
-
done
|
|
127
|
-
`;
|
|
127
|
+
declare -f "$func" 2>/dev/null
|
|
128
|
+
done`;
|
|
128
129
|
|
|
129
130
|
// Shell options extraction
|
|
130
131
|
const optionsScript = isZsh
|
|
@@ -142,16 +143,45 @@ echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"
|
|
|
142
143
|
return `
|
|
143
144
|
SNAPSHOT_FILE='${escapedPath}'
|
|
144
145
|
|
|
146
|
+
# Snapshot may inline env-var values referenced by captured functions (#3470).
|
|
147
|
+
# Defence in depth: (a) JS caller pre-creates the file at 0600 so the shell's
|
|
148
|
+
# \`>|\`/\`>>\` redirections truncate/append without changing the inode mode,
|
|
149
|
+
# (b) we \`umask 077\` before AND after sourcing the rc so any other file the
|
|
150
|
+
# script creates is private even when the rc resets umask to 022, (c) JS
|
|
151
|
+
# caller chmods file + dir after the script exits.
|
|
152
|
+
umask 077
|
|
153
|
+
|
|
145
154
|
# Source user's rc file if it exists
|
|
146
155
|
${hasRcFile ? `source "${rcFile}" < /dev/null 2>/dev/null` : "# No user config file to source"}
|
|
147
156
|
|
|
157
|
+
# Re-tighten umask in case the rc reset it (common \`.bashrc\`/\`.zshrc\` set
|
|
158
|
+
# \`umask 022\` for the interactive shell).
|
|
159
|
+
umask 077
|
|
160
|
+
|
|
148
161
|
# Create/clear the snapshot file
|
|
149
162
|
echo "# Shell snapshot - generated by omp agent" >| "$SNAPSHOT_FILE"
|
|
150
163
|
|
|
151
164
|
# Unalias everything first to avoid conflicts when sourced
|
|
152
165
|
echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"
|
|
153
166
|
|
|
154
|
-
|
|
167
|
+
# Capture function definitions into a variable so we can both persist them
|
|
168
|
+
# and scan their bodies for env-var references (issue #3470).
|
|
169
|
+
__omp_funcs=$(
|
|
170
|
+
${functionExtractor}
|
|
171
|
+
)
|
|
172
|
+
echo "# Functions" >> "$SNAPSHOT_FILE"
|
|
173
|
+
printf '%s\\n' "$__omp_funcs" >> "$SNAPSHOT_FILE"
|
|
174
|
+
|
|
175
|
+
# Re-export uppercase identifiers referenced by snapshotted functions whose
|
|
176
|
+
# value is set in the rc-sourced shell. Without this, activation idioms like
|
|
177
|
+
# mise's \`mise()\` -> \`command "$__MISE_EXE" "$@"\` blow up because the helper
|
|
178
|
+
# var is lost (issue #3470). Helper definitions are POSIX-shell so the same
|
|
179
|
+
# block works for both bash and zsh.
|
|
180
|
+
echo "# Captured function environment" >> "$SNAPSHOT_FILE"
|
|
181
|
+
${fnEnvHelper}
|
|
182
|
+
printf '%s\\n' "$__omp_funcs" | __omp_emit_referenced_exports >> "$SNAPSHOT_FILE"
|
|
183
|
+
unset -f __omp_sq_quote __omp_emit_export_for __omp_emit_referenced_exports 2>/dev/null
|
|
184
|
+
unset __omp_funcs __omp_qbuf __omp_qout __omp_sq __omp_xv __omp_name 2>/dev/null
|
|
155
185
|
|
|
156
186
|
${optionsScript}
|
|
157
187
|
|
|
@@ -198,16 +228,35 @@ export async function getOrCreateSnapshot(
|
|
|
198
228
|
return null;
|
|
199
229
|
}
|
|
200
230
|
|
|
201
|
-
const rcFile = getShellConfigFile(shell);
|
|
231
|
+
const rcFile = getShellConfigFile(shell, env);
|
|
202
232
|
|
|
203
|
-
// Create snapshot directory
|
|
233
|
+
// Create snapshot directory with owner-only perms — the script may inline
|
|
234
|
+
// env vars referenced by captured functions (#3470) and `os.tmpdir()` is
|
|
235
|
+
// shared on Linux. `mode: 0o700` applies to a fresh mkdir; an existing dir
|
|
236
|
+
// keeps its mode, so chmod it defensively. Ignore EPERM (dir owned by
|
|
237
|
+
// another user on a shared box).
|
|
204
238
|
const snapshotDir = path.join(os.tmpdir(), "omp-shell-snapshots");
|
|
205
|
-
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
239
|
+
fs.mkdirSync(snapshotDir, { recursive: true, mode: 0o700 });
|
|
240
|
+
try {
|
|
241
|
+
fs.chmodSync(snapshotDir, 0o700);
|
|
242
|
+
} catch {
|
|
243
|
+
// best-effort
|
|
244
|
+
}
|
|
206
245
|
|
|
207
246
|
// Generate unique snapshot path
|
|
208
247
|
const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
|
|
209
248
|
const snapshotPath = path.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
|
|
210
249
|
|
|
250
|
+
// Pre-create the snapshot file at 0600 so the shell's `>|` (truncate) and
|
|
251
|
+
// `>>` (append) redirections inside `generateSnapshotScript` operate on an
|
|
252
|
+
// existing inode and preserve the private mode, regardless of the umask
|
|
253
|
+
// state inside the spawned shell. Without this, a `.bashrc` that sets
|
|
254
|
+
// `umask 022` (the typical interactive default) before the script's first
|
|
255
|
+
// redirection would create the file world-readable; the JS-side post-spawn
|
|
256
|
+
// chmod would tighten it, but only after the shell finished writing every
|
|
257
|
+
// captured env value to disk.
|
|
258
|
+
fs.writeFileSync(snapshotPath, "", { mode: 0o600 });
|
|
259
|
+
|
|
211
260
|
// Generate and execute snapshot script
|
|
212
261
|
const script = generateSnapshotScript(shell, snapshotPath, rcFile);
|
|
213
262
|
|
|
@@ -230,6 +279,14 @@ export async function getOrCreateSnapshot(
|
|
|
230
279
|
|
|
231
280
|
await child.exited;
|
|
232
281
|
if (child.exitCode === 0 && fs.existsSync(snapshotPath)) {
|
|
282
|
+
// Defence-in-depth: the script's `umask 077` already locks the file at
|
|
283
|
+
// first write, but chmod again in case the umask didn't take (exotic
|
|
284
|
+
// shells) or a postmortem-restored file ended up looser.
|
|
285
|
+
try {
|
|
286
|
+
fs.chmodSync(snapshotPath, 0o600);
|
|
287
|
+
} catch {
|
|
288
|
+
// best-effort
|
|
289
|
+
}
|
|
233
290
|
scrubSnapshotInPlace(snapshotPath);
|
|
234
291
|
cachedSnapshotPaths.set(cacheKey, snapshotPath);
|
|
235
292
|
return snapshotPath;
|