pi-agent-browser-native 0.2.12 → 0.2.13
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 +11 -0
- package/README.md +87 -27
- package/docs/ARCHITECTURE.md +9 -3
- package/docs/COMMAND_REFERENCE.md +383 -151
- package/docs/RELEASE.md +81 -26
- package/docs/REQUIREMENTS.md +10 -4
- package/docs/TOOL_CONTRACT.md +51 -11
- package/extensions/agent-browser/index.ts +845 -343
- package/extensions/agent-browser/lib/parsing.ts +20 -0
- package/extensions/agent-browser/lib/playbook.ts +79 -0
- package/extensions/agent-browser/lib/process.ts +56 -8
- package/extensions/agent-browser/lib/results/confirmation.ts +76 -0
- package/extensions/agent-browser/lib/results/envelope.ts +42 -5
- package/extensions/agent-browser/lib/results/presentation.ts +907 -50
- package/extensions/agent-browser/lib/results/shared.ts +166 -15
- package/extensions/agent-browser/lib/results/snapshot.ts +69 -7
- package/extensions/agent-browser/lib/results.ts +7 -1
- package/extensions/agent-browser/lib/runtime.ts +204 -15
- package/extensions/agent-browser/lib/temp.ts +131 -23
- package/package.json +9 -6
- package/scripts/agent-browser-capability-baseline.mjs +104 -0
- package/scripts/doctor.mjs +420 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Purpose: Share stable result-rendering types and small data-shaping helpers across the focused result modules.
|
|
3
|
-
* Responsibilities: Define upstream envelope/presentation types, provide safe
|
|
3
|
+
* Responsibilities: Define upstream envelope/presentation types, provide safe string utilities, and expose lightweight text helpers used by envelope parsing, snapshot compaction, and presentation rendering.
|
|
4
4
|
* Scope: Shared result helpers only; higher-level parsing, snapshot compaction, and image attachment orchestration live in neighboring modules.
|
|
5
5
|
* Usage: Imported by the focused result modules that back the public `lib/results.ts` facade.
|
|
6
6
|
* Invariants/Assumptions: Helpers stay generic, side-effect free, and small enough to reuse without reintroducing a new god module.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export interface AgentBrowserEnvelope {
|
|
10
10
|
data?: unknown;
|
|
11
11
|
error?: unknown;
|
|
12
|
-
success
|
|
12
|
+
success: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface AgentBrowserBatchResult {
|
|
@@ -19,7 +19,164 @@ export interface AgentBrowserBatchResult {
|
|
|
19
19
|
success?: boolean;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export type FileArtifactKind = "download" | "file" | "har" | "image" | "pdf" | "profile" | "trace" | "video";
|
|
23
|
+
|
|
24
|
+
export interface FileArtifactMetadata {
|
|
25
|
+
absolutePath: string;
|
|
26
|
+
command?: string;
|
|
27
|
+
exists?: boolean;
|
|
28
|
+
extension?: string;
|
|
29
|
+
kind: FileArtifactKind;
|
|
30
|
+
mediaType?: string;
|
|
31
|
+
path: string;
|
|
32
|
+
sizeBytes?: number;
|
|
33
|
+
subcommand?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SavedFilePresentationDetails {
|
|
37
|
+
command: "download" | "pdf" | "wait";
|
|
38
|
+
kind: "download" | "pdf";
|
|
39
|
+
metadata?: Record<string, unknown>;
|
|
40
|
+
path: string;
|
|
41
|
+
subcommand?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ArtifactRetentionState = "evicted" | "ephemeral" | "live" | "missing";
|
|
45
|
+
|
|
46
|
+
export type ArtifactStorageScope = "explicit-path" | "persistent-session" | "process-temp";
|
|
47
|
+
|
|
48
|
+
export interface SessionArtifactManifestEntry {
|
|
49
|
+
absolutePath?: string;
|
|
50
|
+
command?: string;
|
|
51
|
+
createdAtMs: number;
|
|
52
|
+
evictedAtMs?: number;
|
|
53
|
+
exists?: boolean;
|
|
54
|
+
extension?: string;
|
|
55
|
+
kind: FileArtifactKind | "spill";
|
|
56
|
+
mediaType?: string;
|
|
57
|
+
path: string;
|
|
58
|
+
retentionState: ArtifactRetentionState;
|
|
59
|
+
sizeBytes?: number;
|
|
60
|
+
storageScope: ArtifactStorageScope;
|
|
61
|
+
subcommand?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SessionArtifactManifest {
|
|
65
|
+
entries: SessionArtifactManifestEntry[];
|
|
66
|
+
evictedCount: number;
|
|
67
|
+
liveCount: number;
|
|
68
|
+
maxEntries: number;
|
|
69
|
+
updatedAtMs: number;
|
|
70
|
+
version: 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const SESSION_ARTIFACT_MANIFEST_VERSION = 1;
|
|
74
|
+
export const SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES_ENV = "PI_AGENT_BROWSER_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES";
|
|
75
|
+
export const DEFAULT_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES = 100;
|
|
76
|
+
|
|
77
|
+
function parsePositiveSafeInteger(value: string | undefined): number | undefined {
|
|
78
|
+
if (value === undefined) return undefined;
|
|
79
|
+
const parsed = Number(value);
|
|
80
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined;
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getSessionArtifactManifestMaxEntries(env: NodeJS.ProcessEnv = process.env): number {
|
|
85
|
+
return parsePositiveSafeInteger(env[SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES_ENV]) ?? DEFAULT_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
return typeof value === "object" && value !== null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isManifestEntry(value: unknown): value is SessionArtifactManifestEntry {
|
|
93
|
+
if (!isRecord(value)) return false;
|
|
94
|
+
if (typeof value.path !== "string" || value.path.trim().length === 0) return false;
|
|
95
|
+
if (typeof value.createdAtMs !== "number" || !Number.isFinite(value.createdAtMs)) return false;
|
|
96
|
+
if (!["evicted", "ephemeral", "live", "missing"].includes(String(value.retentionState))) return false;
|
|
97
|
+
if (!["explicit-path", "persistent-session", "process-temp"].includes(String(value.storageScope))) return false;
|
|
98
|
+
if (typeof value.kind !== "string" || value.kind.trim().length === 0) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isSessionArtifactManifest(value: unknown): value is SessionArtifactManifest {
|
|
103
|
+
if (!isRecord(value)) return false;
|
|
104
|
+
if (value.version !== SESSION_ARTIFACT_MANIFEST_VERSION) return false;
|
|
105
|
+
if (!Array.isArray(value.entries) || !value.entries.every(isManifestEntry)) return false;
|
|
106
|
+
if (typeof value.updatedAtMs !== "number" || !Number.isFinite(value.updatedAtMs)) return false;
|
|
107
|
+
if (typeof value.maxEntries !== "number" || !Number.isSafeInteger(value.maxEntries) || value.maxEntries <= 0) return false;
|
|
108
|
+
if (typeof value.liveCount !== "number" || !Number.isSafeInteger(value.liveCount) || value.liveCount < 0) return false;
|
|
109
|
+
if (typeof value.evictedCount !== "number" || !Number.isSafeInteger(value.evictedCount) || value.evictedCount < 0) return false;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildEvictedSessionArtifactEntries(
|
|
114
|
+
evictedArtifacts: Array<{ mtimeMs: number; path: string; sizeBytes: number }>,
|
|
115
|
+
nowMs: number,
|
|
116
|
+
): SessionArtifactManifestEntry[] {
|
|
117
|
+
return evictedArtifacts.map((artifact) => ({
|
|
118
|
+
createdAtMs: artifact.mtimeMs,
|
|
119
|
+
evictedAtMs: nowMs,
|
|
120
|
+
kind: "spill",
|
|
121
|
+
path: artifact.path,
|
|
122
|
+
retentionState: "evicted",
|
|
123
|
+
sizeBytes: artifact.sizeBytes,
|
|
124
|
+
storageScope: "persistent-session",
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatSessionArtifactRetentionSummary(manifest: SessionArtifactManifest): string {
|
|
129
|
+
const ephemeralCount = manifest.entries.filter((entry) => entry.retentionState === "ephemeral").length;
|
|
130
|
+
const missingCount = manifest.entries.filter((entry) => entry.retentionState === "missing").length;
|
|
131
|
+
const parts = [`${manifest.liveCount} live`, `${manifest.evictedCount} evicted`];
|
|
132
|
+
if (ephemeralCount > 0) parts.push(`${ephemeralCount} ephemeral`);
|
|
133
|
+
if (missingCount > 0) parts.push(`${missingCount} missing`);
|
|
134
|
+
return `Session artifacts: ${parts.join(", ")} (${manifest.entries.length}/${manifest.maxEntries} recent).`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function mergeSessionArtifactManifest(options: {
|
|
138
|
+
base?: SessionArtifactManifest;
|
|
139
|
+
entries?: SessionArtifactManifestEntry[];
|
|
140
|
+
nowMs?: number;
|
|
141
|
+
}): SessionArtifactManifest | undefined {
|
|
142
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
143
|
+
const maxEntries = getSessionArtifactManifestMaxEntries();
|
|
144
|
+
const getEntryKey = (entry: SessionArtifactManifestEntry) =>
|
|
145
|
+
entry.storageScope === "explicit-path" && entry.absolutePath ? `${entry.storageScope}:${entry.absolutePath}` : `${entry.storageScope}:${entry.path}`;
|
|
146
|
+
const byPath = new Map<string, SessionArtifactManifestEntry>();
|
|
147
|
+
for (const entry of options.base?.entries ?? []) {
|
|
148
|
+
byPath.set(getEntryKey(entry), entry);
|
|
149
|
+
}
|
|
150
|
+
for (const entry of options.entries ?? []) {
|
|
151
|
+
const key = getEntryKey(entry);
|
|
152
|
+
const existing = byPath.get(key);
|
|
153
|
+
byPath.set(key, {
|
|
154
|
+
...existing,
|
|
155
|
+
...entry,
|
|
156
|
+
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
|
157
|
+
evictedAtMs: entry.retentionState === "evicted" ? (entry.evictedAtMs ?? nowMs) : entry.evictedAtMs,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (byPath.size === 0) return undefined;
|
|
161
|
+
const entries = [...byPath.values()]
|
|
162
|
+
.sort((left, right) => {
|
|
163
|
+
const leftTime = left.evictedAtMs ?? left.createdAtMs;
|
|
164
|
+
const rightTime = right.evictedAtMs ?? right.createdAtMs;
|
|
165
|
+
return rightTime - leftTime || left.path.localeCompare(right.path);
|
|
166
|
+
})
|
|
167
|
+
.slice(0, maxEntries);
|
|
168
|
+
return {
|
|
169
|
+
entries,
|
|
170
|
+
evictedCount: entries.filter((entry) => entry.retentionState === "evicted").length,
|
|
171
|
+
liveCount: entries.filter((entry) => entry.retentionState === "live").length,
|
|
172
|
+
maxEntries,
|
|
173
|
+
updatedAtMs: nowMs,
|
|
174
|
+
version: SESSION_ARTIFACT_MANIFEST_VERSION,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
22
178
|
export interface BatchStepPresentationDetails {
|
|
179
|
+
artifacts?: FileArtifactMetadata[];
|
|
23
180
|
command?: string[];
|
|
24
181
|
commandText: string;
|
|
25
182
|
data?: unknown;
|
|
@@ -28,6 +185,8 @@ export interface BatchStepPresentationDetails {
|
|
|
28
185
|
imagePath?: string;
|
|
29
186
|
imagePaths?: string[];
|
|
30
187
|
index: number;
|
|
188
|
+
savedFile?: SavedFilePresentationDetails;
|
|
189
|
+
savedFilePath?: string;
|
|
31
190
|
success: boolean;
|
|
32
191
|
summary: string;
|
|
33
192
|
text: string;
|
|
@@ -41,6 +200,9 @@ export interface BatchFailurePresentationDetails {
|
|
|
41
200
|
}
|
|
42
201
|
|
|
43
202
|
export interface ToolPresentation {
|
|
203
|
+
artifactManifest?: SessionArtifactManifest;
|
|
204
|
+
artifactRetentionSummary?: string;
|
|
205
|
+
artifacts?: FileArtifactMetadata[];
|
|
44
206
|
batchFailure?: BatchFailurePresentationDetails;
|
|
45
207
|
batchSteps?: BatchStepPresentationDetails[];
|
|
46
208
|
content: Array<{ text: string; type: "text" } | { data: string; mimeType: string; type: "image" }>;
|
|
@@ -49,13 +211,11 @@ export interface ToolPresentation {
|
|
|
49
211
|
fullOutputPaths?: string[];
|
|
50
212
|
imagePath?: string;
|
|
51
213
|
imagePaths?: string[];
|
|
214
|
+
savedFile?: SavedFilePresentationDetails;
|
|
215
|
+
savedFilePath?: string;
|
|
52
216
|
summary: string;
|
|
53
217
|
}
|
|
54
218
|
|
|
55
|
-
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
-
return typeof value === "object" && value !== null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
219
|
export function stringifyUnknown(value: unknown): string {
|
|
60
220
|
if (typeof value === "string") return value;
|
|
61
221
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
@@ -67,15 +227,6 @@ export function stringifyUnknown(value: unknown): string {
|
|
|
67
227
|
}
|
|
68
228
|
}
|
|
69
229
|
|
|
70
|
-
export function parsePositiveInteger(rawValue: string | undefined): number | undefined {
|
|
71
|
-
if (typeof rawValue !== "string") return undefined;
|
|
72
|
-
const normalizedValue = rawValue.trim();
|
|
73
|
-
if (!/^\d+$/.test(normalizedValue)) return undefined;
|
|
74
|
-
const parsedValue = Number(normalizedValue);
|
|
75
|
-
if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) return undefined;
|
|
76
|
-
return parsedValue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
230
|
export function countLines(text: string): number {
|
|
80
231
|
return text.length === 0 ? 0 : text.split("\n").length;
|
|
81
232
|
}
|
|
@@ -6,8 +6,25 @@
|
|
|
6
6
|
* Invariants/Assumptions: Snapshot compaction should stay helpful even if upstream snapshot text formatting shifts, so structured parsing is best-effort and always has a resilient raw-outline fallback.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { isRecord } from "../parsing.js";
|
|
10
|
+
import {
|
|
11
|
+
type PersistentSessionArtifactEviction,
|
|
12
|
+
type PersistentSessionArtifactStore,
|
|
13
|
+
writePersistentSessionArtifactFile,
|
|
14
|
+
writeSecureTempFile,
|
|
15
|
+
} from "../temp.js";
|
|
16
|
+
import {
|
|
17
|
+
type SessionArtifactManifest,
|
|
18
|
+
type SessionArtifactManifestEntry,
|
|
19
|
+
type ToolPresentation,
|
|
20
|
+
buildEvictedSessionArtifactEntries,
|
|
21
|
+
compareRefIds,
|
|
22
|
+
countLines,
|
|
23
|
+
formatSessionArtifactRetentionSummary,
|
|
24
|
+
mergeSessionArtifactManifest,
|
|
25
|
+
normalizeWhitespace,
|
|
26
|
+
truncateText,
|
|
27
|
+
} from "./shared.js";
|
|
11
28
|
|
|
12
29
|
const SNAPSHOT_INLINE_MAX_CHARS = 6_000;
|
|
13
30
|
const SNAPSHOT_INLINE_MAX_LINES = 80;
|
|
@@ -463,18 +480,49 @@ function canUseStructuredSnapshotPreview(snapshotLines: SnapshotLine[], refEntri
|
|
|
463
480
|
);
|
|
464
481
|
}
|
|
465
482
|
|
|
483
|
+
interface SnapshotSpillWriteResult {
|
|
484
|
+
evictedArtifacts: PersistentSessionArtifactEviction[];
|
|
485
|
+
path: string;
|
|
486
|
+
storageScope: "persistent-session" | "process-temp";
|
|
487
|
+
}
|
|
488
|
+
|
|
466
489
|
async function writeSnapshotSpillFile(
|
|
467
490
|
data: Record<string, unknown>,
|
|
468
491
|
persistentArtifactStore: PersistentSessionArtifactStore | undefined,
|
|
469
|
-
): Promise<
|
|
492
|
+
): Promise<SnapshotSpillWriteResult> {
|
|
470
493
|
const options = {
|
|
471
494
|
content: JSON.stringify(data, null, 2),
|
|
472
495
|
prefix: SNAPSHOT_SPILL_FILE_PREFIX,
|
|
473
496
|
suffix: ".json",
|
|
474
497
|
};
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
:
|
|
498
|
+
if (persistentArtifactStore) {
|
|
499
|
+
const result = await writePersistentSessionArtifactFile({ ...options, store: persistentArtifactStore });
|
|
500
|
+
return { ...result, storageScope: "persistent-session" };
|
|
501
|
+
}
|
|
502
|
+
return { evictedArtifacts: [], path: await writeSecureTempFile(options), storageScope: "process-temp" };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function applySnapshotArtifactManifest(options: {
|
|
506
|
+
baseManifest?: SessionArtifactManifest;
|
|
507
|
+
command?: string;
|
|
508
|
+
fullOutputPath?: string;
|
|
509
|
+
spill?: SnapshotSpillWriteResult;
|
|
510
|
+
}): { artifactManifest?: SessionArtifactManifest; artifactRetentionSummary?: string } {
|
|
511
|
+
if (!options.fullOutputPath || !options.spill) return {};
|
|
512
|
+
const nowMs = Date.now();
|
|
513
|
+
const entries: SessionArtifactManifestEntry[] = [
|
|
514
|
+
{
|
|
515
|
+
command: options.command,
|
|
516
|
+
createdAtMs: nowMs,
|
|
517
|
+
kind: "spill",
|
|
518
|
+
path: options.fullOutputPath,
|
|
519
|
+
retentionState: options.spill.storageScope === "persistent-session" ? "live" : "ephemeral",
|
|
520
|
+
storageScope: options.spill.storageScope,
|
|
521
|
+
},
|
|
522
|
+
...buildEvictedSessionArtifactEntries(options.spill.evictedArtifacts, nowMs),
|
|
523
|
+
];
|
|
524
|
+
const artifactManifest = mergeSessionArtifactManifest({ base: options.baseManifest, entries, nowMs });
|
|
525
|
+
return artifactManifest ? { artifactManifest, artifactRetentionSummary: formatSessionArtifactRetentionSummary(artifactManifest) } : {};
|
|
478
526
|
}
|
|
479
527
|
|
|
480
528
|
export function formatSnapshotSummary(data: Record<string, unknown>): string {
|
|
@@ -496,6 +544,7 @@ export function formatRawSnapshotText(data: Record<string, unknown>): string {
|
|
|
496
544
|
export async function buildSnapshotPresentation(
|
|
497
545
|
data: Record<string, unknown>,
|
|
498
546
|
persistentArtifactStore: PersistentSessionArtifactStore | undefined = undefined,
|
|
547
|
+
artifactManifest: SessionArtifactManifest | undefined = undefined,
|
|
499
548
|
): Promise<ToolPresentation> {
|
|
500
549
|
const summary = formatSnapshotSummary(data);
|
|
501
550
|
const rawText = formatRawSnapshotText(data);
|
|
@@ -508,9 +557,11 @@ export async function buildSnapshotPresentation(
|
|
|
508
557
|
}
|
|
509
558
|
|
|
510
559
|
let fullOutputPath: string | undefined;
|
|
560
|
+
let spill: SnapshotSpillWriteResult | undefined;
|
|
511
561
|
let spillErrorText: string | undefined;
|
|
512
562
|
try {
|
|
513
|
-
|
|
563
|
+
spill = await writeSnapshotSpillFile(data, persistentArtifactStore);
|
|
564
|
+
fullOutputPath = spill.path;
|
|
514
565
|
} catch (error) {
|
|
515
566
|
spillErrorText = error instanceof Error ? error.message : String(error);
|
|
516
567
|
}
|
|
@@ -618,7 +669,18 @@ export async function buildSnapshotPresentation(
|
|
|
618
669
|
: `Full raw snapshot unavailable: ${spillErrorText ?? "temp spill file could not be created."}`,
|
|
619
670
|
);
|
|
620
671
|
|
|
672
|
+
const manifestFields = applySnapshotArtifactManifest({
|
|
673
|
+
baseManifest: artifactManifest,
|
|
674
|
+
command: "snapshot",
|
|
675
|
+
fullOutputPath,
|
|
676
|
+
spill,
|
|
677
|
+
});
|
|
678
|
+
if (manifestFields.artifactRetentionSummary) {
|
|
679
|
+
lines.push("", manifestFields.artifactRetentionSummary);
|
|
680
|
+
}
|
|
681
|
+
|
|
621
682
|
return {
|
|
683
|
+
...manifestFields,
|
|
622
684
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
623
685
|
data: {
|
|
624
686
|
compacted: true,
|
|
@@ -8,4 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
export { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "./results/envelope.js";
|
|
10
10
|
export { buildToolPresentation } from "./results/presentation.js";
|
|
11
|
-
export type {
|
|
11
|
+
export type {
|
|
12
|
+
AgentBrowserBatchResult,
|
|
13
|
+
AgentBrowserEnvelope,
|
|
14
|
+
FileArtifactKind,
|
|
15
|
+
FileArtifactMetadata,
|
|
16
|
+
ToolPresentation,
|
|
17
|
+
} from "./results/shared.js";
|
|
@@ -3,13 +3,63 @@
|
|
|
3
3
|
* Responsibilities: Validate raw tool arguments, derive extension-managed session names from the pi session identity, restore managed-session state from persisted tool details, redact sensitive invocation text, classify browser-oriented prompts, and build the effective CLI argument list passed to the upstream agent-browser binary.
|
|
4
4
|
* Scope: Pure runtime-planning helpers only; no subprocess execution or filesystem access lives here.
|
|
5
5
|
* Usage: Imported by the extension entrypoint and unit tests before spawning the upstream CLI.
|
|
6
|
-
* Invariants/Assumptions: The wrapper stays thin, preserves upstream command vocabulary, keeps plain-text inspection stateless,
|
|
6
|
+
* Invariants/Assumptions: The wrapper stays thin, preserves upstream command vocabulary, keeps plain-text inspection stateless,
|
|
7
|
+
* and only injects wrapper-owned flags: `--json`, an extension-managed `--session` when appropriate, and the narrow
|
|
8
|
+
* OpenAI/ChatGPT headless compatibility `--user-agent` when that workaround applies.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
12
|
import { basename } from "node:path";
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
import { isRecord } from "./parsing.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Launch-scoped flags that select the upstream browser session/auth mechanism at launch time.
|
|
18
|
+
*
|
|
19
|
+
* These flags must not be silently appended after an already-active extension-managed session
|
|
20
|
+
* because upstream ignores or conflicts with them once a session is reused. Every flag here
|
|
21
|
+
* participates in implicit-session validation blocking and recovery-hint generation.
|
|
22
|
+
*
|
|
23
|
+
* Intentionally excluded from the tab-correction subset:
|
|
24
|
+
* - `--auto-connect` attaches to a running browser but is a general-purpose debug/attach mode,
|
|
25
|
+
* not a state-restore mechanism that typically leaves restored tabs stealing focus.
|
|
26
|
+
* - `--cdp` connects to an arbitrary endpoint; similar reasoning to `--auto-connect`.
|
|
27
|
+
*
|
|
28
|
+
* Other flags like `--headed`, `--engine`, `--executable-path`, `--user-agent`, and
|
|
29
|
+
* `--download-path` are first-launch-sensitive but not alternate session/auth attach
|
|
30
|
+
* mechanisms, so they are intentionally excluded from the full launch-scoped set.
|
|
31
|
+
*/
|
|
32
|
+
const LAUNCH_SCOPED_FLAG_DEFINITIONS = [
|
|
33
|
+
{
|
|
34
|
+
flag: "--auto-connect",
|
|
35
|
+
reason: "attaches to an already-running browser at launch time instead of reusing an existing named session",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
flag: "--cdp",
|
|
39
|
+
reason: "selects the browser/CDP endpoint used when an upstream session is launched",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
flag: "--profile",
|
|
43
|
+
reason: "selects Chrome profile state for the upstream launch",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
flag: "--session-name",
|
|
47
|
+
reason: "selects upstream saved auth/session state for the launch",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
flag: "--state",
|
|
51
|
+
reason: "loads persisted upstream browser/auth state at launch time",
|
|
52
|
+
},
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
const LAUNCH_SCOPED_FLAG_LABEL = LAUNCH_SCOPED_FLAG_DEFINITIONS.map((definition) => definition.flag).join(", ");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The subset of launch-scoped flags that can restore browser/auth state with pre-existing tabs
|
|
59
|
+
* and are plausible wrong-active-tab sources after a fresh launch. These trigger post-open
|
|
60
|
+
* tab-correction (the `tab list` + re-select cycle).
|
|
61
|
+
*/
|
|
62
|
+
const LAUNCH_SCOPED_TAB_CORRECTION_FLAGS = new Set(["--profile", "--session-name", "--state"] as const);
|
|
13
63
|
const OPEN_COMMANDS = new Set(["goto", "navigate", "open"]);
|
|
14
64
|
const OPENAI_HEADLESS_COMPAT_HOSTS = new Set(["chat.com", "chat.openai.com", "chatgpt.com"]);
|
|
15
65
|
const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
|
|
@@ -140,10 +190,6 @@ export interface PromptPolicy {
|
|
|
140
190
|
allowLegacyAgentBrowserBash: boolean;
|
|
141
191
|
}
|
|
142
192
|
|
|
143
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
144
|
-
return typeof value === "object" && value !== null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
193
|
function isStringArray(value: unknown): value is string[] {
|
|
148
194
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
149
195
|
}
|
|
@@ -164,23 +210,26 @@ function redactUrlToken(token: string): string {
|
|
|
164
210
|
return token;
|
|
165
211
|
}
|
|
166
212
|
|
|
213
|
+
let mutated = false;
|
|
167
214
|
if (parsed.username.length > 0) {
|
|
168
215
|
parsed.username = "[REDACTED]";
|
|
216
|
+
mutated = true;
|
|
169
217
|
}
|
|
170
218
|
if (parsed.password.length > 0) {
|
|
171
219
|
parsed.password = "[REDACTED]";
|
|
220
|
+
mutated = true;
|
|
172
221
|
}
|
|
173
222
|
|
|
174
223
|
for (const [name] of parsed.searchParams) {
|
|
175
224
|
if (shouldRedactQueryParam(name)) {
|
|
176
225
|
parsed.searchParams.set(name, "[REDACTED]");
|
|
226
|
+
mutated = true;
|
|
177
227
|
}
|
|
178
228
|
}
|
|
179
229
|
|
|
180
230
|
const hashText = parsed.hash.startsWith("#") ? parsed.hash.slice(1) : parsed.hash;
|
|
181
231
|
if (hashText.includes("=")) {
|
|
182
232
|
const hashParams = new URLSearchParams(hashText);
|
|
183
|
-
let mutated = false;
|
|
184
233
|
for (const [name] of hashParams) {
|
|
185
234
|
if (shouldRedactQueryParam(name)) {
|
|
186
235
|
hashParams.set(name, "[REDACTED]");
|
|
@@ -199,10 +248,95 @@ function redactLooseUrlMatches(text: string): string {
|
|
|
199
248
|
return text.replace(/\b(?:https?|wss?):\/\/[^\s"'`<>\])]+/g, (match) => redactUrlToken(match));
|
|
200
249
|
}
|
|
201
250
|
|
|
251
|
+
function findBalancedJsonEnd(text: string, startIndex: number): number | undefined {
|
|
252
|
+
const opener = text[startIndex];
|
|
253
|
+
const closer = opener === "{" ? "}" : opener === "[" ? "]" : undefined;
|
|
254
|
+
if (!closer) return undefined;
|
|
255
|
+
const stack = [closer];
|
|
256
|
+
let inString = false;
|
|
257
|
+
let escaped = false;
|
|
258
|
+
for (let index = startIndex + 1; index < text.length; index += 1) {
|
|
259
|
+
const char = text[index];
|
|
260
|
+
if (inString) {
|
|
261
|
+
if (escaped) {
|
|
262
|
+
escaped = false;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (char === "\\") {
|
|
266
|
+
escaped = true;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (char === '"') {
|
|
270
|
+
inString = false;
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (char === '"') {
|
|
275
|
+
inString = true;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (char === "{") {
|
|
279
|
+
stack.push("}");
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (char === "[") {
|
|
283
|
+
stack.push("]");
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (char === "}" || char === "]") {
|
|
287
|
+
if (stack.pop() !== char) return undefined;
|
|
288
|
+
if (stack.length === 0) return index;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function redactEmbeddedStructuredText(text: string): string {
|
|
295
|
+
let output = "";
|
|
296
|
+
let cursor = 0;
|
|
297
|
+
while (cursor < text.length) {
|
|
298
|
+
const char = text[cursor];
|
|
299
|
+
if (char !== "{" && char !== "[") {
|
|
300
|
+
output += char;
|
|
301
|
+
cursor += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const endIndex = findBalancedJsonEnd(text, cursor);
|
|
305
|
+
if (endIndex === undefined) {
|
|
306
|
+
output += char;
|
|
307
|
+
cursor += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const candidate = text.slice(cursor, endIndex + 1);
|
|
311
|
+
try {
|
|
312
|
+
const parsed = JSON.parse(candidate) as unknown;
|
|
313
|
+
const redacted = typeof parsed === "string" ? redactSensitiveText(parsed) : JSON.stringify(redactSensitiveValue(parsed));
|
|
314
|
+
const original = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
|
|
315
|
+
output += redacted === original ? candidate : redacted;
|
|
316
|
+
} catch {
|
|
317
|
+
output += candidate;
|
|
318
|
+
}
|
|
319
|
+
cursor = endIndex + 1;
|
|
320
|
+
}
|
|
321
|
+
return output;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function redactStandaloneBasicCredential(text: string): string {
|
|
325
|
+
return text.replace(/\b(Basic)\s+([A-Za-z0-9+/=]{12,})/gi, (match, label: string, credential: string) => {
|
|
326
|
+
if (!/[0-9+/=]/.test(credential)) return match;
|
|
327
|
+
return `${label} [REDACTED]`;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
202
331
|
export function redactSensitiveText(text: string): string {
|
|
203
|
-
return
|
|
204
|
-
|
|
205
|
-
|
|
332
|
+
return redactEmbeddedStructuredText(
|
|
333
|
+
redactStandaloneBasicCredential(
|
|
334
|
+
redactLooseUrlMatches(text)
|
|
335
|
+
.replace(/\b(Bearer)\s+[^\s",]+/gi, "$1 [REDACTED]")
|
|
336
|
+
.replace(/\b(Authorization\s*:\s*Basic)\s+[^\s",]+/gi, "$1 [REDACTED]")
|
|
337
|
+
.replace(/\b(Cookie|Set-Cookie)\s*:\s*[^\n\r"]+/gi, "$1: [REDACTED]"),
|
|
338
|
+
),
|
|
339
|
+
);
|
|
206
340
|
}
|
|
207
341
|
|
|
208
342
|
export function redactSensitiveValue(value: unknown): unknown {
|
|
@@ -327,6 +461,27 @@ function isRestorableManagedSessionName(sessionName: string, fallbackSessionName
|
|
|
327
461
|
return sessionName === fallbackSessionName || sessionName.startsWith(`${fallbackSessionName}-fresh-`);
|
|
328
462
|
}
|
|
329
463
|
|
|
464
|
+
function getManagedSessionRestoreRank(options: {
|
|
465
|
+
fallbackSessionName: string;
|
|
466
|
+
freshSessionRanks: Map<string, number>;
|
|
467
|
+
sessionName: string;
|
|
468
|
+
}): number | undefined {
|
|
469
|
+
const { fallbackSessionName, freshSessionRanks, sessionName } = options;
|
|
470
|
+
if (sessionName === fallbackSessionName) {
|
|
471
|
+
return 0;
|
|
472
|
+
}
|
|
473
|
+
if (!sessionName.startsWith(`${fallbackSessionName}-fresh-`)) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
const existingRank = freshSessionRanks.get(sessionName);
|
|
477
|
+
if (existingRank !== undefined) {
|
|
478
|
+
return existingRank;
|
|
479
|
+
}
|
|
480
|
+
const nextRank = freshSessionRanks.size + 1;
|
|
481
|
+
freshSessionRanks.set(sessionName, nextRank);
|
|
482
|
+
return nextRank;
|
|
483
|
+
}
|
|
484
|
+
|
|
330
485
|
export function restoreManagedSessionStateFromBranch(
|
|
331
486
|
branch: unknown[],
|
|
332
487
|
fallbackSessionName: string,
|
|
@@ -335,7 +490,9 @@ export function restoreManagedSessionStateFromBranch(
|
|
|
335
490
|
active: false,
|
|
336
491
|
sessionName: fallbackSessionName,
|
|
337
492
|
};
|
|
493
|
+
let activeRestoreRank = 0;
|
|
338
494
|
let freshSessionOrdinal = 0;
|
|
495
|
+
const freshSessionRanks = new Map<string, number>();
|
|
339
496
|
|
|
340
497
|
for (const entry of branch) {
|
|
341
498
|
if (!isRecord(entry) || entry.type !== "message") {
|
|
@@ -369,10 +526,31 @@ export function restoreManagedSessionStateFromBranch(
|
|
|
369
526
|
continue;
|
|
370
527
|
}
|
|
371
528
|
|
|
529
|
+
const restoreRank = getManagedSessionRestoreRank({
|
|
530
|
+
fallbackSessionName,
|
|
531
|
+
freshSessionRanks,
|
|
532
|
+
sessionName: managedSessionName,
|
|
533
|
+
});
|
|
534
|
+
if (restoreRank === undefined) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
|
|
372
538
|
const messageIsError = typeof message.isError === "boolean" ? message.isError : undefined;
|
|
373
539
|
const exitCode = typeof details.exitCode === "number" ? details.exitCode : undefined;
|
|
374
540
|
const succeeded = messageIsError === undefined ? exitCode === undefined || exitCode === 0 : !messageIsError;
|
|
375
541
|
const command = typeof details.command === "string" ? details.command : parseCommandInfo(args).command;
|
|
542
|
+
if (succeeded && sessionMode === "fresh") {
|
|
543
|
+
freshSessionOrdinal += 1;
|
|
544
|
+
}
|
|
545
|
+
const staleCompletion = succeeded && command !== "close" && restoreRank < activeRestoreRank;
|
|
546
|
+
if (staleCompletion) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const staleClose = command === "close" && restoredState.active && managedSessionName !== restoredState.sessionName;
|
|
550
|
+
if (staleClose) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
376
554
|
restoredState = resolveManagedSessionState({
|
|
377
555
|
command,
|
|
378
556
|
managedSessionName,
|
|
@@ -380,8 +558,8 @@ export function restoreManagedSessionStateFromBranch(
|
|
|
380
558
|
priorSessionName: restoredState.sessionName,
|
|
381
559
|
succeeded,
|
|
382
560
|
});
|
|
383
|
-
if (succeeded &&
|
|
384
|
-
|
|
561
|
+
if (succeeded && command !== "close" && restoredState.active) {
|
|
562
|
+
activeRestoreRank = restoreRank;
|
|
385
563
|
}
|
|
386
564
|
}
|
|
387
565
|
|
|
@@ -619,7 +797,18 @@ export function extractExplicitSessionName(args: string[]): string | undefined {
|
|
|
619
797
|
}
|
|
620
798
|
|
|
621
799
|
export function getStartupScopedFlags(args: string[]): string[] {
|
|
622
|
-
return
|
|
800
|
+
return LAUNCH_SCOPED_FLAG_DEFINITIONS
|
|
801
|
+
.map((definition) => definition.flag)
|
|
802
|
+
.filter((flag) => hasFlagToken(args, flag));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export function hasLaunchScopedTabCorrectionFlag(args: string[]): boolean {
|
|
806
|
+
return args.some((token) => {
|
|
807
|
+
for (const flag of LAUNCH_SCOPED_TAB_CORRECTION_FLAGS) {
|
|
808
|
+
if (token === flag || token.startsWith(`${flag}=`)) return true;
|
|
809
|
+
}
|
|
810
|
+
return false;
|
|
811
|
+
});
|
|
623
812
|
}
|
|
624
813
|
|
|
625
814
|
export function buildPromptPolicy(prompt: string): PromptPolicy {
|
|
@@ -708,11 +897,11 @@ export function buildExecutionPlan(
|
|
|
708
897
|
exampleArgs: args,
|
|
709
898
|
exampleParams: { args, sessionMode: "fresh" },
|
|
710
899
|
reason:
|
|
711
|
-
|
|
900
|
+
`Launch-scoped flags (${LAUNCH_SCOPED_FLAG_LABEL}) need a fresh upstream launch once the extension-managed session is already active.`,
|
|
712
901
|
recommendedSessionMode: "fresh",
|
|
713
902
|
};
|
|
714
903
|
validationError = [
|
|
715
|
-
`The current extension-managed agent-browser session is already running, so
|
|
904
|
+
`The current extension-managed agent-browser session is already running, so launch-scoped flags ${startupScopedFlags.join(", ")} would be ignored by upstream agent-browser.`,
|
|
716
905
|
"Retry this call with `sessionMode: \"fresh\"` to force a fresh upstream launch, or pass an explicit `--session ...` if you want to name the new session yourself.",
|
|
717
906
|
].join(" ");
|
|
718
907
|
} else {
|