pi-openai-toolkit 0.14.5 → 0.14.7
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/docs/internals.md +42 -21
- package/package.json +1 -1
- package/src/auto-mode/classifier.ts +165 -165
- package/src/auto-mode/policy.ts +146 -146
- package/src/auto-mode/prompt.ts +279 -279
- package/src/auto-mode/reviewer.ts +325 -325
- package/src/auto-mode/transcript.ts +133 -133
- package/src/auto-mode/types.ts +137 -137
- package/src/codex-astra/effort-planner.ts +175 -175
- package/src/codex-astra/extension.ts +122 -122
- package/src/context-management/codex-provider.ts +20 -20
- package/src/context-management/history-notes.ts +32 -0
- package/src/context-management/messages.ts +112 -106
- package/src/context-management/namespace-tools.ts +426 -426
- package/src/context-management/tool-contract.ts +23 -23
- package/src/context-management/tools.ts +279 -311
- package/src/context-management/types.ts +7 -0
- package/src/context-management/window-budget.ts +83 -83
- package/src/context-management/window-manager.ts +60 -51
- package/src/context-management/window-request.ts +125 -125
- package/src/debug.ts +1 -1
- package/src/extension-runtime.ts +34 -9
- package/src/image-generation/artifacts.ts +237 -237
- package/src/image-generation/client.ts +248 -248
- package/src/image-generation/eligibility.ts +18 -18
- package/src/image-generation/references.ts +194 -194
- package/src/image-generation/render.ts +252 -252
- package/src/model-scope.ts +17 -17
- package/src/provider-headers.ts +23 -23
- package/src/responses-headers.ts +76 -11
- package/src/runtime.ts +37 -1
- package/src/web-search/extension.ts +80 -80
- package/src/web-search/payload.ts +96 -96
- package/src/web-search/prompt.ts +20 -20
- package/src/web-search/types.ts +45 -45
package/src/extension-runtime.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
|
|
|
18
18
|
import { CodexContextWindowManager } from "./context-management/window-manager";
|
|
19
19
|
import { registerContextManagementTools } from "./context-management/tools";
|
|
20
20
|
import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
|
|
21
|
+
import { CODEX_GATEWAY_FORWARD_HEADERS } from "./responses-headers";
|
|
21
22
|
import { resolveLatestNativeCompactionEntry } from "./details-store";
|
|
22
23
|
import { runNativeFallbackCompaction } from "./native-fallback";
|
|
23
24
|
import {
|
|
@@ -398,6 +399,7 @@ async function handleSessionBeforeCompact(
|
|
|
398
399
|
{
|
|
399
400
|
enabled: config.enabled,
|
|
400
401
|
responsesApis: config.responsesApis,
|
|
402
|
+
codexGatewayModels: config.contextManagement === "remote" ? config.gatewayContextModels : [],
|
|
401
403
|
},
|
|
402
404
|
config.remoteCompactModel,
|
|
403
405
|
);
|
|
@@ -536,9 +538,15 @@ async function handleContext(
|
|
|
536
538
|
}
|
|
537
539
|
|
|
538
540
|
// An eligible Codex Remote session is never allowed to fall through to the
|
|
539
|
-
// older compaction-replay path.
|
|
540
|
-
//
|
|
541
|
+
// older compaction-replay path. Gateway traffic must also fail closed when
|
|
542
|
+
// its transport capability is unavailable; otherwise Pi could send an
|
|
543
|
+
// ordinary unscoped request and lose CPA OAuth/session affinity.
|
|
541
544
|
if (isCodexContextModel(ctx.model, config)) {
|
|
545
|
+
if (isCodexGatewayModel(ctx.model, config.gatewayContextModels)) {
|
|
546
|
+
notifyRemoteContextFailure(ctx, "codex-context-unavailable");
|
|
547
|
+
ctx.abort();
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
542
550
|
const visibleMessages = contextWindows.project(event.messages, "off");
|
|
543
551
|
return visibleMessages.length === event.messages.length && visibleMessages.every((message, index) => message === event.messages[index])
|
|
544
552
|
? undefined
|
|
@@ -554,6 +562,7 @@ async function handleContext(
|
|
|
554
562
|
const resolution = await resolveNativeCompactionEnvironment(ctx, {
|
|
555
563
|
enabled: config.enabled,
|
|
556
564
|
responsesApis: config.responsesApis,
|
|
565
|
+
codexGatewayModels: config.contextManagement === "remote" ? config.gatewayContextModels : [],
|
|
557
566
|
});
|
|
558
567
|
if (!resolution.ok) return undefined;
|
|
559
568
|
const branchEntries = ctx.sessionManager.getBranch();
|
|
@@ -601,8 +610,14 @@ async function handleBeforeProviderRequest(
|
|
|
601
610
|
}
|
|
602
611
|
}
|
|
603
612
|
if (isCodexContextModel(ctx.model, config)) {
|
|
604
|
-
// Keep Codex Remote mutually exclusive with the legacy replay
|
|
605
|
-
//
|
|
613
|
+
// Keep Codex Remote mutually exclusive with the legacy replay pipeline
|
|
614
|
+
// when authentication or tool ownership is unavailable. Gateway traffic
|
|
615
|
+
// cannot continue as an ordinary Responses request because that would
|
|
616
|
+
// silently lose the required CPA session/account selection.
|
|
617
|
+
if (isCodexGatewayModel(ctx.model, config.gatewayContextModels)) {
|
|
618
|
+
notifyRemoteContextFailure(ctx, "codex-context-unavailable");
|
|
619
|
+
ctx.abort();
|
|
620
|
+
}
|
|
606
621
|
return undefined;
|
|
607
622
|
}
|
|
608
623
|
|
|
@@ -611,6 +626,7 @@ async function handleBeforeProviderRequest(
|
|
|
611
626
|
{
|
|
612
627
|
enabled: config.enabled,
|
|
613
628
|
responsesApis: config.responsesApis,
|
|
629
|
+
codexGatewayModels: config.contextManagement === "remote" ? config.gatewayContextModels : [],
|
|
614
630
|
},
|
|
615
631
|
event.payload,
|
|
616
632
|
);
|
|
@@ -877,7 +893,14 @@ export default function registerCompactionExtension(
|
|
|
877
893
|
pi.on("before_provider_headers", async (event, ctx) => {
|
|
878
894
|
const config = dependencies.loadConfig().config.compaction;
|
|
879
895
|
if (!isCodexContextModel(ctx.model, config)) return;
|
|
880
|
-
|
|
896
|
+
const active = await remoteContextActive(ctx, config);
|
|
897
|
+
if (!active) {
|
|
898
|
+
if (isCodexGatewayModel(ctx.model, config.gatewayContextModels)) {
|
|
899
|
+
notifyRemoteContextFailure(ctx, "codex-context-unavailable");
|
|
900
|
+
ctx.abort();
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
881
904
|
const provider = await resolveCodexContextProvider(ctx, ctx.model, config.gatewayContextModels);
|
|
882
905
|
if (provider.ok && provider.provider.kind === "codex-gateway") {
|
|
883
906
|
const sessionId = getSessionId(ctx);
|
|
@@ -885,6 +908,10 @@ export default function registerCompactionExtension(
|
|
|
885
908
|
sessionId,
|
|
886
909
|
clientRequestId: sessionId,
|
|
887
910
|
});
|
|
911
|
+
const allowedGatewayHeaders = new Set<string>(CODEX_GATEWAY_FORWARD_HEADERS);
|
|
912
|
+
for (const existing of Object.keys(event.headers)) {
|
|
913
|
+
if (!allowedGatewayHeaders.has(existing.toLowerCase())) delete event.headers[existing];
|
|
914
|
+
}
|
|
888
915
|
for (const name of [
|
|
889
916
|
"authorization",
|
|
890
917
|
"originator",
|
|
@@ -901,15 +928,13 @@ export default function registerCompactionExtension(
|
|
|
901
928
|
const value = gatewayHeaders.get(name);
|
|
902
929
|
if (value) event.headers[name] = value;
|
|
903
930
|
}
|
|
904
|
-
for (const existing of Object.keys(event.headers)) {
|
|
905
|
-
if (["cookie", "chatgpt-account-id", "x-api-key"].includes(existing.toLowerCase())) delete event.headers[existing];
|
|
906
|
-
}
|
|
907
931
|
}
|
|
908
932
|
contextWindows.rewriteHeaders(event.headers, ctx);
|
|
909
933
|
});
|
|
910
|
-
pi.on("message_end", (event, ctx) => {
|
|
934
|
+
pi.on("message_end", async (event, ctx) => {
|
|
911
935
|
const config = dependencies.loadConfig().config.compaction;
|
|
912
936
|
if (!isCodexContextModel(ctx.model, config) || !tools.isRegistered) return undefined;
|
|
937
|
+
if (!(await remoteContextActive(ctx, config))) return undefined;
|
|
913
938
|
const message = routeContextNamespaceToolMessage(event.message);
|
|
914
939
|
return message === event.message ? undefined : { message };
|
|
915
940
|
});
|
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
import { constants as fsConstants } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
link,
|
|
4
|
-
mkdir,
|
|
5
|
-
open,
|
|
6
|
-
realpath,
|
|
7
|
-
stat,
|
|
8
|
-
unlink,
|
|
9
|
-
} from "node:fs/promises";
|
|
10
|
-
import { randomUUID } from "node:crypto";
|
|
11
|
-
import {
|
|
12
|
-
basename,
|
|
13
|
-
dirname,
|
|
14
|
-
extname,
|
|
15
|
-
isAbsolute,
|
|
16
|
-
join,
|
|
17
|
-
relative,
|
|
18
|
-
resolve,
|
|
19
|
-
sep,
|
|
20
|
-
} from "node:path";
|
|
21
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
-
import {
|
|
23
|
-
MAX_IMAGE_PATH_CHARS,
|
|
24
|
-
ImageGenerationError,
|
|
25
|
-
sanitizeImageDiagnostic,
|
|
26
|
-
} from "./types";
|
|
27
|
-
|
|
28
|
-
export type ExplicitOutputPlan = {
|
|
29
|
-
path: string;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
function isPathInsideOrEqual(root: string, candidate: string): boolean {
|
|
33
|
-
const rel = relative(root, candidate);
|
|
34
|
-
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sanitizePathPart(value: string, fallback: string): string {
|
|
38
|
-
const sanitized = value
|
|
39
|
-
.trim()
|
|
40
|
-
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
41
|
-
.replace(/^[.-]+|[.-]+$/g, "")
|
|
42
|
-
.slice(0, 120);
|
|
43
|
-
return sanitized || fallback;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function realDirectory(path: string): Promise<string> {
|
|
47
|
-
return realpath(path).catch(() => resolve(path));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function resolveWriteTarget(candidate: string): Promise<string> {
|
|
51
|
-
const suffix: string[] = [];
|
|
52
|
-
let current = resolve(candidate);
|
|
53
|
-
while (true) {
|
|
54
|
-
try {
|
|
55
|
-
const info = await stat(current);
|
|
56
|
-
if (suffix.length > 0 && !info.isDirectory()) {
|
|
57
|
-
throw new ImageGenerationError(
|
|
58
|
-
"output-path-invalid",
|
|
59
|
-
`Output parent is not a directory: ${current}`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
const existing = await realpath(current);
|
|
63
|
-
return resolve(existing, ...suffix);
|
|
64
|
-
} catch (error) {
|
|
65
|
-
if (error instanceof ImageGenerationError) throw error;
|
|
66
|
-
if (
|
|
67
|
-
error &&
|
|
68
|
-
typeof error === "object" &&
|
|
69
|
-
"code" in error &&
|
|
70
|
-
error.code !== "ENOENT"
|
|
71
|
-
) {
|
|
72
|
-
throw new ImageGenerationError(
|
|
73
|
-
"output-path-invalid",
|
|
74
|
-
`Unable to inspect output path: ${candidate}`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
const parent = dirname(current);
|
|
78
|
-
if (parent === current) {
|
|
79
|
-
throw new ImageGenerationError(
|
|
80
|
-
"output-path-invalid",
|
|
81
|
-
`Unable to resolve output path: ${candidate}`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
suffix.unshift(basename(current));
|
|
85
|
-
current = parent;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
91
|
-
if (signal?.aborted) {
|
|
92
|
-
throw new ImageGenerationError("aborted", "Image generation was cancelled.");
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function getGeneratedImagesRoot(agentDir: string): string {
|
|
97
|
-
return join(resolve(agentDir), "generated-images");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function prepareExplicitOutputPath(args: {
|
|
101
|
-
rawPath?: string;
|
|
102
|
-
agentDir: string;
|
|
103
|
-
ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "isProjectTrusted" | "ui">;
|
|
104
|
-
signal?: AbortSignal;
|
|
105
|
-
}): Promise<ExplicitOutputPlan | undefined> {
|
|
106
|
-
if (args.rawPath === undefined) return undefined;
|
|
107
|
-
const trimmed = args.rawPath.trim();
|
|
108
|
-
if (!trimmed || trimmed.length > MAX_IMAGE_PATH_CHARS || extname(trimmed).toLowerCase() !== ".png") {
|
|
109
|
-
throw new ImageGenerationError(
|
|
110
|
-
"output-path-invalid",
|
|
111
|
-
"outputPath must be a non-empty .png file path.",
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
throwIfAborted(args.signal);
|
|
116
|
-
const lexical = isAbsolute(trimmed) ? resolve(trimmed) : resolve(args.ctx.cwd, trimmed);
|
|
117
|
-
const target = await resolveWriteTarget(lexical);
|
|
118
|
-
const targetInfo = await stat(target).catch(() => undefined);
|
|
119
|
-
if (targetInfo) {
|
|
120
|
-
throw new ImageGenerationError(
|
|
121
|
-
"output-path-invalid",
|
|
122
|
-
`Output path already exists and will not be overwritten: ${target}`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const [agentRoot, projectRoot] = await Promise.all([
|
|
127
|
-
realDirectory(args.agentDir),
|
|
128
|
-
realDirectory(args.ctx.cwd),
|
|
129
|
-
]);
|
|
130
|
-
if (isPathInsideOrEqual(agentRoot, target)) return { path: target };
|
|
131
|
-
|
|
132
|
-
if (isPathInsideOrEqual(projectRoot, target)) {
|
|
133
|
-
if (!args.ctx.isProjectTrusted()) {
|
|
134
|
-
throw new ImageGenerationError(
|
|
135
|
-
"output-path-invalid",
|
|
136
|
-
"Writing generated images inside an untrusted project is not allowed.",
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
return { path: target };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (!args.ctx.hasUI) {
|
|
143
|
-
throw new ImageGenerationError(
|
|
144
|
-
"output-path-approval-required",
|
|
145
|
-
"Writing outside the Pi agent root or trusted project requires interactive approval.",
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
const approved = await args.ctx.ui.confirm(
|
|
149
|
-
"Write generated image outside safe roots?",
|
|
150
|
-
[
|
|
151
|
-
"The generated PNG will be copied to this external path without overwriting an existing file:",
|
|
152
|
-
"",
|
|
153
|
-
target,
|
|
154
|
-
"",
|
|
155
|
-
"Continue?",
|
|
156
|
-
].join("\n"),
|
|
157
|
-
{ signal: args.signal },
|
|
158
|
-
);
|
|
159
|
-
throwIfAborted(args.signal);
|
|
160
|
-
if (!approved) {
|
|
161
|
-
throw new ImageGenerationError("output-path-declined", "External output path was declined.");
|
|
162
|
-
}
|
|
163
|
-
return { path: target };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function publishBytesNoOverwrite(bytes: Uint8Array, target: string): Promise<void> {
|
|
167
|
-
const parent = dirname(target);
|
|
168
|
-
await mkdir(parent, { recursive: true });
|
|
169
|
-
const tempPath = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
|
|
170
|
-
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
171
|
-
try {
|
|
172
|
-
handle = await open(tempPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
173
|
-
await handle.writeFile(bytes);
|
|
174
|
-
await handle.sync();
|
|
175
|
-
await handle.close();
|
|
176
|
-
handle = undefined;
|
|
177
|
-
await link(tempPath, target);
|
|
178
|
-
} finally {
|
|
179
|
-
await handle?.close().catch(() => undefined);
|
|
180
|
-
await unlink(tempPath).catch(() => undefined);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function isAlreadyExistsError(error: unknown): boolean {
|
|
185
|
-
return !!error && typeof error === "object" && "code" in error && error.code === "EEXIST";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export async function saveCanonicalImage(args: {
|
|
189
|
-
bytes: Uint8Array;
|
|
190
|
-
agentDir: string;
|
|
191
|
-
sessionId: string;
|
|
192
|
-
imageCallId: string;
|
|
193
|
-
}): Promise<string> {
|
|
194
|
-
const root = getGeneratedImagesRoot(args.agentDir);
|
|
195
|
-
const sessionPart = sanitizePathPart(args.sessionId, "session");
|
|
196
|
-
const imagePart = sanitizePathPart(args.imageCallId, "image_generation");
|
|
197
|
-
const directory = join(root, sessionPart);
|
|
198
|
-
|
|
199
|
-
for (let index = 1; index <= 1000; index += 1) {
|
|
200
|
-
const suffix = index === 1 ? "" : `-${index}`;
|
|
201
|
-
const target = join(directory, `${imagePart}${suffix}.png`);
|
|
202
|
-
try {
|
|
203
|
-
await publishBytesNoOverwrite(args.bytes, target);
|
|
204
|
-
return target;
|
|
205
|
-
} catch (error) {
|
|
206
|
-
if (isAlreadyExistsError(error)) continue;
|
|
207
|
-
throw new ImageGenerationError(
|
|
208
|
-
"artifact-write-failed",
|
|
209
|
-
sanitizeImageDiagnostic(
|
|
210
|
-
error instanceof Error ? error.message : error,
|
|
211
|
-
"Failed to persist the generated image artifact.",
|
|
212
|
-
),
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
throw new ImageGenerationError(
|
|
217
|
-
"artifact-write-failed",
|
|
218
|
-
"Failed to reserve a unique generated image artifact path.",
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export async function copyImageToExplicitPath(args: {
|
|
223
|
-
bytes: Uint8Array;
|
|
224
|
-
plan: ExplicitOutputPlan;
|
|
225
|
-
}): Promise<void> {
|
|
226
|
-
const currentTarget = await resolveWriteTarget(args.plan.path);
|
|
227
|
-
if (currentTarget !== args.plan.path) {
|
|
228
|
-
throw new Error("Output path changed after approval.");
|
|
229
|
-
}
|
|
230
|
-
await publishBytesNoOverwrite(args.bytes, args.plan.path);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export const _artifactTest = {
|
|
234
|
-
isPathInsideOrEqual,
|
|
235
|
-
publishBytesNoOverwrite,
|
|
236
|
-
resolveWriteTarget,
|
|
237
|
-
};
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
link,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
realpath,
|
|
7
|
+
stat,
|
|
8
|
+
unlink,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
extname,
|
|
15
|
+
isAbsolute,
|
|
16
|
+
join,
|
|
17
|
+
relative,
|
|
18
|
+
resolve,
|
|
19
|
+
sep,
|
|
20
|
+
} from "node:path";
|
|
21
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import {
|
|
23
|
+
MAX_IMAGE_PATH_CHARS,
|
|
24
|
+
ImageGenerationError,
|
|
25
|
+
sanitizeImageDiagnostic,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
export type ExplicitOutputPlan = {
|
|
29
|
+
path: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isPathInsideOrEqual(root: string, candidate: string): boolean {
|
|
33
|
+
const rel = relative(root, candidate);
|
|
34
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizePathPart(value: string, fallback: string): string {
|
|
38
|
+
const sanitized = value
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
41
|
+
.replace(/^[.-]+|[.-]+$/g, "")
|
|
42
|
+
.slice(0, 120);
|
|
43
|
+
return sanitized || fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function realDirectory(path: string): Promise<string> {
|
|
47
|
+
return realpath(path).catch(() => resolve(path));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function resolveWriteTarget(candidate: string): Promise<string> {
|
|
51
|
+
const suffix: string[] = [];
|
|
52
|
+
let current = resolve(candidate);
|
|
53
|
+
while (true) {
|
|
54
|
+
try {
|
|
55
|
+
const info = await stat(current);
|
|
56
|
+
if (suffix.length > 0 && !info.isDirectory()) {
|
|
57
|
+
throw new ImageGenerationError(
|
|
58
|
+
"output-path-invalid",
|
|
59
|
+
`Output parent is not a directory: ${current}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const existing = await realpath(current);
|
|
63
|
+
return resolve(existing, ...suffix);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof ImageGenerationError) throw error;
|
|
66
|
+
if (
|
|
67
|
+
error &&
|
|
68
|
+
typeof error === "object" &&
|
|
69
|
+
"code" in error &&
|
|
70
|
+
error.code !== "ENOENT"
|
|
71
|
+
) {
|
|
72
|
+
throw new ImageGenerationError(
|
|
73
|
+
"output-path-invalid",
|
|
74
|
+
`Unable to inspect output path: ${candidate}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const parent = dirname(current);
|
|
78
|
+
if (parent === current) {
|
|
79
|
+
throw new ImageGenerationError(
|
|
80
|
+
"output-path-invalid",
|
|
81
|
+
`Unable to resolve output path: ${candidate}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
suffix.unshift(basename(current));
|
|
85
|
+
current = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
throw new ImageGenerationError("aborted", "Image generation was cancelled.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getGeneratedImagesRoot(agentDir: string): string {
|
|
97
|
+
return join(resolve(agentDir), "generated-images");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function prepareExplicitOutputPath(args: {
|
|
101
|
+
rawPath?: string;
|
|
102
|
+
agentDir: string;
|
|
103
|
+
ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "isProjectTrusted" | "ui">;
|
|
104
|
+
signal?: AbortSignal;
|
|
105
|
+
}): Promise<ExplicitOutputPlan | undefined> {
|
|
106
|
+
if (args.rawPath === undefined) return undefined;
|
|
107
|
+
const trimmed = args.rawPath.trim();
|
|
108
|
+
if (!trimmed || trimmed.length > MAX_IMAGE_PATH_CHARS || extname(trimmed).toLowerCase() !== ".png") {
|
|
109
|
+
throw new ImageGenerationError(
|
|
110
|
+
"output-path-invalid",
|
|
111
|
+
"outputPath must be a non-empty .png file path.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throwIfAborted(args.signal);
|
|
116
|
+
const lexical = isAbsolute(trimmed) ? resolve(trimmed) : resolve(args.ctx.cwd, trimmed);
|
|
117
|
+
const target = await resolveWriteTarget(lexical);
|
|
118
|
+
const targetInfo = await stat(target).catch(() => undefined);
|
|
119
|
+
if (targetInfo) {
|
|
120
|
+
throw new ImageGenerationError(
|
|
121
|
+
"output-path-invalid",
|
|
122
|
+
`Output path already exists and will not be overwritten: ${target}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [agentRoot, projectRoot] = await Promise.all([
|
|
127
|
+
realDirectory(args.agentDir),
|
|
128
|
+
realDirectory(args.ctx.cwd),
|
|
129
|
+
]);
|
|
130
|
+
if (isPathInsideOrEqual(agentRoot, target)) return { path: target };
|
|
131
|
+
|
|
132
|
+
if (isPathInsideOrEqual(projectRoot, target)) {
|
|
133
|
+
if (!args.ctx.isProjectTrusted()) {
|
|
134
|
+
throw new ImageGenerationError(
|
|
135
|
+
"output-path-invalid",
|
|
136
|
+
"Writing generated images inside an untrusted project is not allowed.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return { path: target };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!args.ctx.hasUI) {
|
|
143
|
+
throw new ImageGenerationError(
|
|
144
|
+
"output-path-approval-required",
|
|
145
|
+
"Writing outside the Pi agent root or trusted project requires interactive approval.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const approved = await args.ctx.ui.confirm(
|
|
149
|
+
"Write generated image outside safe roots?",
|
|
150
|
+
[
|
|
151
|
+
"The generated PNG will be copied to this external path without overwriting an existing file:",
|
|
152
|
+
"",
|
|
153
|
+
target,
|
|
154
|
+
"",
|
|
155
|
+
"Continue?",
|
|
156
|
+
].join("\n"),
|
|
157
|
+
{ signal: args.signal },
|
|
158
|
+
);
|
|
159
|
+
throwIfAborted(args.signal);
|
|
160
|
+
if (!approved) {
|
|
161
|
+
throw new ImageGenerationError("output-path-declined", "External output path was declined.");
|
|
162
|
+
}
|
|
163
|
+
return { path: target };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function publishBytesNoOverwrite(bytes: Uint8Array, target: string): Promise<void> {
|
|
167
|
+
const parent = dirname(target);
|
|
168
|
+
await mkdir(parent, { recursive: true });
|
|
169
|
+
const tempPath = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
|
|
170
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
171
|
+
try {
|
|
172
|
+
handle = await open(tempPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
173
|
+
await handle.writeFile(bytes);
|
|
174
|
+
await handle.sync();
|
|
175
|
+
await handle.close();
|
|
176
|
+
handle = undefined;
|
|
177
|
+
await link(tempPath, target);
|
|
178
|
+
} finally {
|
|
179
|
+
await handle?.close().catch(() => undefined);
|
|
180
|
+
await unlink(tempPath).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
185
|
+
return !!error && typeof error === "object" && "code" in error && error.code === "EEXIST";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function saveCanonicalImage(args: {
|
|
189
|
+
bytes: Uint8Array;
|
|
190
|
+
agentDir: string;
|
|
191
|
+
sessionId: string;
|
|
192
|
+
imageCallId: string;
|
|
193
|
+
}): Promise<string> {
|
|
194
|
+
const root = getGeneratedImagesRoot(args.agentDir);
|
|
195
|
+
const sessionPart = sanitizePathPart(args.sessionId, "session");
|
|
196
|
+
const imagePart = sanitizePathPart(args.imageCallId, "image_generation");
|
|
197
|
+
const directory = join(root, sessionPart);
|
|
198
|
+
|
|
199
|
+
for (let index = 1; index <= 1000; index += 1) {
|
|
200
|
+
const suffix = index === 1 ? "" : `-${index}`;
|
|
201
|
+
const target = join(directory, `${imagePart}${suffix}.png`);
|
|
202
|
+
try {
|
|
203
|
+
await publishBytesNoOverwrite(args.bytes, target);
|
|
204
|
+
return target;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (isAlreadyExistsError(error)) continue;
|
|
207
|
+
throw new ImageGenerationError(
|
|
208
|
+
"artifact-write-failed",
|
|
209
|
+
sanitizeImageDiagnostic(
|
|
210
|
+
error instanceof Error ? error.message : error,
|
|
211
|
+
"Failed to persist the generated image artifact.",
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
throw new ImageGenerationError(
|
|
217
|
+
"artifact-write-failed",
|
|
218
|
+
"Failed to reserve a unique generated image artifact path.",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function copyImageToExplicitPath(args: {
|
|
223
|
+
bytes: Uint8Array;
|
|
224
|
+
plan: ExplicitOutputPlan;
|
|
225
|
+
}): Promise<void> {
|
|
226
|
+
const currentTarget = await resolveWriteTarget(args.plan.path);
|
|
227
|
+
if (currentTarget !== args.plan.path) {
|
|
228
|
+
throw new Error("Output path changed after approval.");
|
|
229
|
+
}
|
|
230
|
+
await publishBytesNoOverwrite(args.bytes, args.plan.path);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const _artifactTest = {
|
|
234
|
+
isPathInsideOrEqual,
|
|
235
|
+
publishBytesNoOverwrite,
|
|
236
|
+
resolveWriteTarget,
|
|
237
|
+
};
|