@oh-my-pi/pi-coding-agent 16.1.20 → 16.1.21
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 +10 -0
- package/dist/cli.js +2804 -2793
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +42 -5
- package/dist/types/modes/components/custom-editor.d.ts +32 -0
- package/dist/types/modes/components/tool-execution.d.ts +5 -5
- package/dist/types/modes/controllers/event-controller.d.ts +10 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -1
- package/dist/types/utils/clipboard.d.ts +10 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
- package/src/advisor/__tests__/advisor.test.ts +44 -0
- package/src/advisor/advise-tool.ts +33 -0
- package/src/autolearn/controller.ts +17 -2
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
- package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
- package/src/mcp/manager.ts +12 -3
- package/src/mcp/oauth-flow.ts +121 -7
- package/src/modes/components/chat-transcript-builder.ts +31 -0
- package/src/modes/components/custom-editor.test.ts +80 -0
- package/src/modes/components/custom-editor.ts +86 -6
- package/src/modes/components/tool-execution.ts +50 -25
- package/src/modes/controllers/event-controller.ts +57 -8
- package/src/modes/controllers/input-controller.ts +70 -27
- package/src/modes/controllers/mcp-command-controller.ts +18 -2
- package/src/modes/utils/ui-helpers.ts +40 -0
- package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
- package/src/prompts/system/autolearn-nudge.md +4 -2
- package/src/prompts/tools/todo.md +1 -1
- package/src/session/agent-session.ts +4 -0
- package/src/tools/todo.ts +20 -10
- package/src/utils/clipboard.ts +57 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { isBuiltin } from "node:module";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import * as url from "node:url";
|
|
4
5
|
import { isCompiledBinary } from "@oh-my-pi/pi-utils";
|
|
@@ -456,10 +457,11 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'
|
|
|
456
457
|
|
|
457
458
|
/**
|
|
458
459
|
* Rewrite the extension-owned specifiers OMP must host-resolve — legacy
|
|
459
|
-
* `@(scope)/pi-*`, bare TypeBox packages,
|
|
460
|
-
* `#src
|
|
461
|
-
*
|
|
462
|
-
*
|
|
460
|
+
* `@(scope)/pi-*`, bare TypeBox packages, package `imports` aliases like
|
|
461
|
+
* `#src/*`, and extension-local bare dependencies — to absolute `file://` URLs
|
|
462
|
+
* or compiled-mode virtual specifiers. Relative siblings and built-in modules
|
|
463
|
+
* are left untouched so Bun resolves them from the extension's real on-disk
|
|
464
|
+
* location.
|
|
463
465
|
*/
|
|
464
466
|
async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
|
|
465
467
|
const withPi = rewriteLegacyPiImports(source);
|
|
@@ -474,7 +476,12 @@ async function rewriteLegacyExtensionSource(source: string, importerPath: string
|
|
|
474
476
|
`${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`,
|
|
475
477
|
)
|
|
476
478
|
: withPi;
|
|
477
|
-
return rewriteExtensionPackageImports(withTypeBox, importerPath);
|
|
479
|
+
return rewriteExtensionBareImports(await rewriteExtensionPackageImports(withTypeBox, importerPath), importerPath);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Test seam for compiled-binary legacy extension source rewriting. */
|
|
483
|
+
export async function __rewriteLegacyExtensionSourceForTests(source: string, importerPath: string): Promise<string> {
|
|
484
|
+
return rewriteLegacyExtensionSource(source, importerPath);
|
|
478
485
|
}
|
|
479
486
|
|
|
480
487
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -688,6 +695,187 @@ async function rewriteExtensionPackageImports(source: string, importerPath: stri
|
|
|
688
695
|
return `${rewritten}${source.slice(lastIndex)}`;
|
|
689
696
|
}
|
|
690
697
|
|
|
698
|
+
const BARE_EXTENSION_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g;
|
|
699
|
+
|
|
700
|
+
function isBareExtensionDependencySpecifier(specifier: string): boolean {
|
|
701
|
+
if (
|
|
702
|
+
specifier.startsWith(".") ||
|
|
703
|
+
specifier.startsWith("/") ||
|
|
704
|
+
specifier.startsWith("#") ||
|
|
705
|
+
specifier.startsWith("node:") ||
|
|
706
|
+
specifier.startsWith("bun:") ||
|
|
707
|
+
/^[a-z][a-z0-9+.-]*:/i.test(specifier)
|
|
708
|
+
) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
|
712
|
+
return Boolean(packageName && !isBuiltin(packageName));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
interface BarePackageSpecifier {
|
|
716
|
+
readonly name: string;
|
|
717
|
+
readonly subpath: string | null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function splitBarePackageSpecifier(specifier: string): BarePackageSpecifier | null {
|
|
721
|
+
const parts = specifier.split("/");
|
|
722
|
+
if (specifier.startsWith("@")) {
|
|
723
|
+
const [scope, name, ...rest] = parts;
|
|
724
|
+
if (!scope || !name) return null;
|
|
725
|
+
return { name: `${scope}/${name}`, subpath: rest.length > 0 ? rest.join("/") : null };
|
|
726
|
+
}
|
|
727
|
+
const [name, ...rest] = parts;
|
|
728
|
+
if (!name) return null;
|
|
729
|
+
return { name, subpath: rest.length > 0 ? rest.join("/") : null };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function findNodePackageRoot(packageName: string, importerPath: string): Promise<string | null> {
|
|
733
|
+
let dir = path.dirname(importerPath);
|
|
734
|
+
while (true) {
|
|
735
|
+
const candidate = path.join(dir, "node_modules", packageName);
|
|
736
|
+
if (await pathExists(path.join(candidate, "package.json"))) {
|
|
737
|
+
return candidate;
|
|
738
|
+
}
|
|
739
|
+
const parent = path.dirname(dir);
|
|
740
|
+
if (parent === dir) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
dir = parent;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
|
|
748
|
+
try {
|
|
749
|
+
const manifest = await Bun.file(path.join(packageRoot, "package.json")).json();
|
|
750
|
+
return isRecord(manifest) ? manifest : null;
|
|
751
|
+
} catch {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function resolvePackageExportTarget(
|
|
757
|
+
packageRoot: string,
|
|
758
|
+
target: string,
|
|
759
|
+
wildcard: string | null,
|
|
760
|
+
): Promise<string | null> {
|
|
761
|
+
if (!target.startsWith("./")) {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
|
|
765
|
+
return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function resolveNodePackageExport(
|
|
769
|
+
packageRoot: string,
|
|
770
|
+
subpath: string | null,
|
|
771
|
+
manifest: Record<string, unknown>,
|
|
772
|
+
): Promise<string | null> {
|
|
773
|
+
const exportsField = manifest.exports;
|
|
774
|
+
const rootTarget = subpath === null ? selectPackageImportTarget(exportsField) : null;
|
|
775
|
+
if (rootTarget !== null && rootTarget !== PACKAGE_IMPORT_EXCLUDED) {
|
|
776
|
+
return resolvePackageExportTarget(packageRoot, rootTarget, null);
|
|
777
|
+
}
|
|
778
|
+
if (!isRecord(exportsField)) {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const exactKey = subpath === null ? "." : `./${subpath}`;
|
|
783
|
+
const exactTarget = selectPackageImportTarget(exportsField[exactKey]);
|
|
784
|
+
if (exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED) {
|
|
785
|
+
return resolvePackageExportTarget(packageRoot, exactTarget, null);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (const [key, entry] of Object.entries(exportsField)) {
|
|
789
|
+
const starIndex = key.indexOf("*");
|
|
790
|
+
if (starIndex === -1 || subpath === null) continue;
|
|
791
|
+
const prefix = key.slice(2, starIndex);
|
|
792
|
+
const suffix = key.slice(starIndex + 1);
|
|
793
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const target = selectPackageImportTarget(entry);
|
|
797
|
+
if (target === null || target === PACKAGE_IMPORT_EXCLUDED) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
return resolvePackageExportTarget(
|
|
801
|
+
packageRoot,
|
|
802
|
+
target,
|
|
803
|
+
subpath.slice(prefix.length, subpath.length - suffix.length),
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function resolveNodePackageFallback(
|
|
810
|
+
packageRoot: string,
|
|
811
|
+
subpath: string | null,
|
|
812
|
+
manifest: Record<string, unknown>,
|
|
813
|
+
): Promise<string | null> {
|
|
814
|
+
if (subpath !== null) {
|
|
815
|
+
return resolveSourceModuleFile(path.join(packageRoot, subpath));
|
|
816
|
+
}
|
|
817
|
+
for (const field of ["module", "main"]) {
|
|
818
|
+
const target = manifest[field];
|
|
819
|
+
if (typeof target === "string") {
|
|
820
|
+
const resolved = await resolveSourceModuleFile(path.resolve(packageRoot, target));
|
|
821
|
+
if (resolved) return resolved;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return resolveSourceModuleFile(path.join(packageRoot, "index"));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function resolveNodePackageDependency(specifier: string, importerPath: string): Promise<string | null> {
|
|
828
|
+
const parsed = splitBarePackageSpecifier(specifier);
|
|
829
|
+
if (!parsed) return null;
|
|
830
|
+
const packageRoot = await findNodePackageRoot(parsed.name, importerPath);
|
|
831
|
+
if (!packageRoot) return null;
|
|
832
|
+
const manifest = await readPackageManifest(packageRoot);
|
|
833
|
+
if (!manifest) return null;
|
|
834
|
+
return (
|
|
835
|
+
(await resolveNodePackageExport(packageRoot, parsed.subpath, manifest)) ??
|
|
836
|
+
(await resolveNodePackageFallback(packageRoot, parsed.subpath, manifest))
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function resolveExtensionBareDependency(specifier: string, importerPath: string): Promise<string | null> {
|
|
841
|
+
if (!isBareExtensionDependencySpecifier(specifier)) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
|
|
846
|
+
if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) {
|
|
847
|
+
return resolved;
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
// Compiled binaries do not reliably resolve runtime extension node_modules.
|
|
851
|
+
}
|
|
852
|
+
return resolveNodePackageDependency(specifier, importerPath);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function rewriteExtensionBareImports(source: string, importerPath: string): Promise<string> {
|
|
856
|
+
let rewritten = "";
|
|
857
|
+
let lastIndex = 0;
|
|
858
|
+
for (const match of source.matchAll(BARE_EXTENSION_IMPORT_SPECIFIER_REGEX)) {
|
|
859
|
+
const matchIndex = match.index;
|
|
860
|
+
if (matchIndex === undefined) continue;
|
|
861
|
+
|
|
862
|
+
const [fullMatch, prefix, specifier, suffix] = match;
|
|
863
|
+
if (!prefix || !specifier || !suffix) continue;
|
|
864
|
+
|
|
865
|
+
const resolved = await resolveExtensionBareDependency(specifier, importerPath);
|
|
866
|
+
if (!resolved) continue;
|
|
867
|
+
|
|
868
|
+
rewritten += source.slice(lastIndex, matchIndex);
|
|
869
|
+
rewritten += `${prefix}${toImportSpecifier(resolved)}${suffix}`;
|
|
870
|
+
lastIndex = matchIndex + fullMatch.length;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (lastIndex === 0) {
|
|
874
|
+
return source;
|
|
875
|
+
}
|
|
876
|
+
return `${rewritten}${source.slice(lastIndex)}`;
|
|
877
|
+
}
|
|
878
|
+
|
|
691
879
|
function escapeRegExp(value: string): string {
|
|
692
880
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
693
881
|
}
|
package/src/mcp/manager.ts
CHANGED
|
@@ -1229,8 +1229,15 @@ export class MCPManager {
|
|
|
1229
1229
|
const tokenUrl = material?.tokenUrl;
|
|
1230
1230
|
const clientId = material?.clientId;
|
|
1231
1231
|
const clientSecret = material?.clientSecret;
|
|
1232
|
-
|
|
1233
|
-
|
|
1232
|
+
// `authorizationUrl` only lives on the embedded credential form;
|
|
1233
|
+
// legacy `MCPAuthConfig` rows never carried it. Required to filter
|
|
1234
|
+
// same-origin resource indicators on refresh when the authorize and
|
|
1235
|
+
// token endpoints sit on different origins (issue #3502 review
|
|
1236
|
+
// follow-up).
|
|
1237
|
+
const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
|
|
1238
|
+
const resourceIsFallback =
|
|
1239
|
+
!material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
|
|
1240
|
+
const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
|
|
1234
1241
|
// Proactive refresh: 5-minute buffer before expiry
|
|
1235
1242
|
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
|
1236
1243
|
const REFRESH_BUFFER_MS = 5 * 60_000;
|
|
@@ -1244,6 +1251,7 @@ export class MCPManager {
|
|
|
1244
1251
|
clientId,
|
|
1245
1252
|
clientSecret,
|
|
1246
1253
|
resource,
|
|
1254
|
+
{ authorizationUrl, stripSameOriginResource: resourceIsFallback },
|
|
1247
1255
|
);
|
|
1248
1256
|
// Spread the old credential first so embedded refresh material survives rotation.
|
|
1249
1257
|
const refreshedCredential: MCPStoredOAuthCredential = {
|
|
@@ -1252,7 +1260,8 @@ export class MCPManager {
|
|
|
1252
1260
|
tokenUrl,
|
|
1253
1261
|
clientId,
|
|
1254
1262
|
clientSecret,
|
|
1255
|
-
resource,
|
|
1263
|
+
resource: resourceIsFallback ? undefined : resource,
|
|
1264
|
+
authorizationUrl,
|
|
1256
1265
|
};
|
|
1257
1266
|
await this.#authStorage.set(credentialId, refreshedCredential);
|
|
1258
1267
|
credential = refreshedCredential;
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -62,6 +62,14 @@ export interface MCPStoredOAuthCredential extends OAuthCredential {
|
|
|
62
62
|
clientId?: string;
|
|
63
63
|
clientSecret?: string;
|
|
64
64
|
resource?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Authorization-server URL (the issuer the grant was minted against). Used
|
|
67
|
+
* to filter same-origin resource indicators on refresh: RFC 8414 lets the
|
|
68
|
+
* authorize and token endpoints sit on different origins, so refresh
|
|
69
|
+
* cannot infer the original auth-server origin from `tokenUrl` alone.
|
|
70
|
+
* Unset on legacy credentials minted before issue #3502's fix.
|
|
71
|
+
*/
|
|
72
|
+
authorizationUrl?: string;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
const DEFAULT_PORT = 3000;
|
|
@@ -169,6 +177,42 @@ function resolveResourceUri(resource: string | undefined): string | undefined {
|
|
|
169
177
|
return trimmed;
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
interface ResourceIndicatorFilterOptions {
|
|
181
|
+
/** Strip any resource URL on the same origin as the authorization server. */
|
|
182
|
+
stripSameOriginResource?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Drop a redundant fallback resource indicator relative to {@link serverUrl}.
|
|
187
|
+
*
|
|
188
|
+
* Provider-advertised resource indicators are authoritative even when they are
|
|
189
|
+
* origin-only (`https://gateway.example.com`) or path-scoped same-origin
|
|
190
|
+
* (`https://gateway.example.com/my-service/mcp`): servers can use either form
|
|
191
|
+
* as the audience they require for the grant.
|
|
192
|
+
*
|
|
193
|
+
* Plane is stricter for OMP-synthesized fallback resources (e.g. using the
|
|
194
|
+
* configured server URL `https://mcp.plane.so/http/mcp` as `resource`), so
|
|
195
|
+
* fallback callers opt into `stripSameOriginResource`. Provider-advertised
|
|
196
|
+
* `oauth.resource` values and authorization-URL `?resource=` values keep the
|
|
197
|
+
* preserving default.
|
|
198
|
+
*/
|
|
199
|
+
function filterResourceIndicator(
|
|
200
|
+
resource: string | undefined,
|
|
201
|
+
serverUrl: string,
|
|
202
|
+
options: ResourceIndicatorFilterOptions = {},
|
|
203
|
+
): string | undefined {
|
|
204
|
+
if (!resource) return undefined;
|
|
205
|
+
try {
|
|
206
|
+
const origin = new URL(serverUrl).origin;
|
|
207
|
+
const parsedResource = new URL(resource);
|
|
208
|
+
if (parsedResource.origin !== origin) return resource;
|
|
209
|
+
if (options.stripSameOriginResource) return undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
// Malformed serverUrl will fail elsewhere; fall through.
|
|
212
|
+
}
|
|
213
|
+
return resource;
|
|
214
|
+
}
|
|
215
|
+
|
|
172
216
|
export interface MCPOAuthConfig {
|
|
173
217
|
/** Authorization endpoint URL */
|
|
174
218
|
authorizationUrl: string;
|
|
@@ -197,6 +241,13 @@ export interface MCPOAuthConfig {
|
|
|
197
241
|
callbackPath?: string;
|
|
198
242
|
/** MCP resource URI for RFC 8707 resource indicators */
|
|
199
243
|
resource?: string;
|
|
244
|
+
/**
|
|
245
|
+
* True when `resource` was synthesized from the server URL fallback rather
|
|
246
|
+
* than advertised by OAuth/protected-resource metadata. Fallback resources
|
|
247
|
+
* are stripped when same-origin with the authorization server; advertised
|
|
248
|
+
* path-scoped resources are preserved.
|
|
249
|
+
*/
|
|
250
|
+
stripSameOriginResource?: boolean;
|
|
200
251
|
/** Fetch implementation for token exchange and discovery requests. */
|
|
201
252
|
fetch?: FetchImpl;
|
|
202
253
|
}
|
|
@@ -219,8 +270,8 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
219
270
|
super(ctrl, resolveCallbackOptions(config));
|
|
220
271
|
this.#resolvedClientId = this.#resolveClientId(config);
|
|
221
272
|
this.#fetch = config.fetch ?? ctrl.fetch ?? fetch;
|
|
222
|
-
this.#resource =
|
|
223
|
-
config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl),
|
|
273
|
+
this.#resource = this.#filterResourceIndicator(
|
|
274
|
+
resolveResourceUri(config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl)),
|
|
224
275
|
);
|
|
225
276
|
}
|
|
226
277
|
|
|
@@ -246,6 +297,15 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
246
297
|
get resource(): string | undefined {
|
|
247
298
|
return this.#resource;
|
|
248
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Authorization-server URL the flow used. Persist alongside the credential
|
|
302
|
+
* so refresh can filter same-origin resource indicators against the issuer's
|
|
303
|
+
* origin even when `tokenUrl` lives on a different origin (RFC 8414 permits
|
|
304
|
+
* the split).
|
|
305
|
+
*/
|
|
306
|
+
get authorizationUrl(): string {
|
|
307
|
+
return this.config.authorizationUrl;
|
|
308
|
+
}
|
|
249
309
|
|
|
250
310
|
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
251
311
|
if (!this.#resolvedClientId) {
|
|
@@ -271,7 +331,20 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
271
331
|
}
|
|
272
332
|
const existingResource = params.get("resource")?.trim();
|
|
273
333
|
if (existingResource) {
|
|
274
|
-
|
|
334
|
+
// A resource already embedded in the provider's authorization URL is
|
|
335
|
+
// provider-authored, not OMP's server-URL fallback. Preserve same-host
|
|
336
|
+
// values here even when the caller marked its separate
|
|
337
|
+
// `config.resource` as fallback; gateway-hosted MCP servers can use
|
|
338
|
+
// origin-only or path-scoped values as the token audience.
|
|
339
|
+
const filtered = filterResourceIndicator(resolveResourceUri(existingResource), this.config.authorizationUrl);
|
|
340
|
+
if (filtered) {
|
|
341
|
+
this.#resource = filtered;
|
|
342
|
+
} else {
|
|
343
|
+
// Defensive path for future policy additions: when filtering says
|
|
344
|
+
// "omit", drop it from both authorize and token requests.
|
|
345
|
+
params.delete("resource");
|
|
346
|
+
this.#resource = undefined;
|
|
347
|
+
}
|
|
275
348
|
} else if (this.#resource) {
|
|
276
349
|
params.set("resource", this.#resource);
|
|
277
350
|
}
|
|
@@ -395,6 +468,17 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
395
468
|
}
|
|
396
469
|
}
|
|
397
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Drop redundant resource indicators for this authorization server.
|
|
473
|
+
* Provider-advertised path-scoped values are preserved; fallback server-URL
|
|
474
|
+
* values opt into same-origin stripping via `stripSameOriginResource`.
|
|
475
|
+
*/
|
|
476
|
+
#filterResourceIndicator(resource: string | undefined): string | undefined {
|
|
477
|
+
return filterResourceIndicator(resource, this.config.authorizationUrl, {
|
|
478
|
+
stripSameOriginResource: this.config.stripSameOriginResource,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
398
482
|
/**
|
|
399
483
|
* Try OAuth dynamic client registration when provider requires a client_id.
|
|
400
484
|
*/
|
|
@@ -507,6 +591,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
507
591
|
}
|
|
508
592
|
}
|
|
509
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Options for {@link refreshMCPOAuthToken}. Carried via the trailing object
|
|
596
|
+
* so positional callers keep working.
|
|
597
|
+
*/
|
|
598
|
+
export interface RefreshMCPOAuthTokenOptions {
|
|
599
|
+
fetch?: FetchImpl;
|
|
600
|
+
/**
|
|
601
|
+
* Authorization-server URL the original grant was minted against. Used to
|
|
602
|
+
* filter same-origin resource indicators on refresh. Defaults to `tokenUrl`'s
|
|
603
|
+
* origin when omitted for legacy credentials.
|
|
604
|
+
*/
|
|
605
|
+
authorizationUrl?: string;
|
|
606
|
+
/**
|
|
607
|
+
* True when the refresh `resource` was synthesized from the server URL
|
|
608
|
+
* fallback because the credential/auth material carried no resource.
|
|
609
|
+
* Preserved advertised resources leave this false/undefined.
|
|
610
|
+
*/
|
|
611
|
+
stripSameOriginResource?: boolean;
|
|
612
|
+
}
|
|
613
|
+
|
|
510
614
|
/**
|
|
511
615
|
* Refresh an MCP OAuth token using the standard refresh_token grant.
|
|
512
616
|
* Returns updated credentials; preserves the old refresh token if the server doesn't rotate it.
|
|
@@ -516,17 +620,27 @@ export async function refreshMCPOAuthToken(
|
|
|
516
620
|
refreshToken: string,
|
|
517
621
|
clientId?: string,
|
|
518
622
|
clientSecret?: string,
|
|
519
|
-
resourceOrOpts?: string |
|
|
520
|
-
opts?:
|
|
623
|
+
resourceOrOpts?: string | RefreshMCPOAuthTokenOptions,
|
|
624
|
+
opts?: RefreshMCPOAuthTokenOptions,
|
|
521
625
|
): Promise<OAuthCredentials> {
|
|
522
|
-
const
|
|
626
|
+
const optsFromTrailing = typeof resourceOrOpts === "string" ? opts : resourceOrOpts;
|
|
627
|
+
const fetchImpl: FetchImpl = optsFromTrailing?.fetch ?? fetch;
|
|
523
628
|
const resource = typeof resourceOrOpts === "string" ? resourceOrOpts : undefined;
|
|
629
|
+
// Filter against the authorization-server origin when known (RFC 8414
|
|
630
|
+
// permits authorize/token endpoints on separate origins). Fall back to
|
|
631
|
+
// `tokenUrl` for legacy credentials minted before the issuer was persisted
|
|
632
|
+
// — same-origin servers (the common case) still match correctly.
|
|
633
|
+
const filterAnchor = optsFromTrailing?.authorizationUrl ?? tokenUrl;
|
|
524
634
|
const params = new URLSearchParams({
|
|
525
635
|
grant_type: "refresh_token",
|
|
526
636
|
refresh_token: refreshToken,
|
|
527
637
|
});
|
|
528
638
|
if (clientId) params.set("client_id", clientId);
|
|
529
|
-
|
|
639
|
+
// Drop redundant indicators so refresh stays consistent with the initial
|
|
640
|
+
// grant; see {@link filterResourceIndicator} for context.
|
|
641
|
+
const resolvedResource = filterResourceIndicator(resolveResourceUri(resource), filterAnchor, {
|
|
642
|
+
stripSameOriginResource: optsFromTrailing?.stripSameOriginResource,
|
|
643
|
+
});
|
|
530
644
|
if (resolvedResource) params.set("resource", resolvedResource);
|
|
531
645
|
if (clientSecret) params.set("client_secret", clientSecret);
|
|
532
646
|
|
|
@@ -83,6 +83,7 @@ export class ChatTranscriptBuilder {
|
|
|
83
83
|
#pendingUsage: Usage | undefined;
|
|
84
84
|
#lastAssistantUsage: Usage | undefined;
|
|
85
85
|
#waitingPoll: ToolExecutionComponent | null = null;
|
|
86
|
+
#todoSnapshot: ToolExecutionComponent | null = null;
|
|
86
87
|
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
|
|
87
88
|
#expanded = false;
|
|
88
89
|
|
|
@@ -128,6 +129,7 @@ export class ChatTranscriptBuilder {
|
|
|
128
129
|
this.#pendingUsage = undefined;
|
|
129
130
|
this.#lastAssistantUsage = undefined;
|
|
130
131
|
this.#waitingPoll = null;
|
|
132
|
+
this.#todoSnapshot = null;
|
|
131
133
|
this.#expandables = [];
|
|
132
134
|
this.container.dispose();
|
|
133
135
|
this.container.clear();
|
|
@@ -153,6 +155,24 @@ export class ChatTranscriptBuilder {
|
|
|
153
155
|
previous.seal();
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
#resolveTodoSnapshot(nextToolName?: string): void {
|
|
159
|
+
const previous = this.#todoSnapshot;
|
|
160
|
+
if (!previous) return;
|
|
161
|
+
if (!previous.isDisplaceableBlock()) {
|
|
162
|
+
this.#todoSnapshot = null;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (previous.canBeDisplacedBy(nextToolName)) {
|
|
166
|
+
this.#todoSnapshot = null;
|
|
167
|
+
this.container.removeChild(previous);
|
|
168
|
+
previous.seal();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (nextToolName !== undefined) return;
|
|
172
|
+
this.#todoSnapshot = null;
|
|
173
|
+
previous.seal();
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
#ensureReadGroup(): ReadToolGroupComponent {
|
|
157
177
|
if (!this.#readGroup) {
|
|
158
178
|
this.#readGroup = new ReadToolGroupComponent({
|
|
@@ -189,6 +209,7 @@ export class ChatTranscriptBuilder {
|
|
|
189
209
|
case "developer": {
|
|
190
210
|
// A user prompt closes the poll-displacement window, same as the live path.
|
|
191
211
|
if (message.role === "user") this.#resolveWaitingPoll();
|
|
212
|
+
if (message.role === "user") this.#resolveTodoSnapshot();
|
|
192
213
|
const textContent = message.role === "user" ? userMessageText(message) : "";
|
|
193
214
|
if (textContent) {
|
|
194
215
|
const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
|
|
@@ -345,6 +366,16 @@ export class ChatTranscriptBuilder {
|
|
|
345
366
|
this.#pendingTools.delete(message.toolCallId);
|
|
346
367
|
if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
|
|
347
368
|
this.#waitingPoll = pending;
|
|
369
|
+
} else if (
|
|
370
|
+
message.toolName === "todo" &&
|
|
371
|
+
pending instanceof ToolExecutionComponent &&
|
|
372
|
+
pending.canBeDisplacedBy("todo")
|
|
373
|
+
) {
|
|
374
|
+
// A successful todo result supersedes the prior live snapshot. Failed
|
|
375
|
+
// follow-ups return false from canBeDisplacedBy("todo"), so the
|
|
376
|
+
// last-good panel stays on screen.
|
|
377
|
+
this.#resolveTodoSnapshot("todo");
|
|
378
|
+
this.#todoSnapshot = pending;
|
|
348
379
|
}
|
|
349
380
|
}
|
|
350
381
|
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
CustomEditor,
|
|
6
6
|
extractBracketedImagePastePaths,
|
|
7
7
|
extractBracketedPastePaths,
|
|
8
|
+
extractImagePathFromText,
|
|
9
|
+
extractPastePathsFromText,
|
|
8
10
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
9
11
|
SPACE_HOLD_RELEASE_MS,
|
|
10
12
|
SPACE_REPEAT_MAX_GAP_MS,
|
|
@@ -88,6 +90,22 @@ describe("CustomEditor bracketed path paste", () => {
|
|
|
88
90
|
]);
|
|
89
91
|
});
|
|
90
92
|
|
|
93
|
+
it("strips `file://` URLs to the local filesystem path before loading the image", () => {
|
|
94
|
+
// macOS / Ghostty / iTerm2 sometimes forward the pasteboard's
|
|
95
|
+
// `public.file-url` representation when the user does Finder→Copy
|
|
96
|
+
// then Cmd+V. Without decoding, `loadImageInput` would try to read a
|
|
97
|
+
// literal `file:///…` path and fail.
|
|
98
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/Pictures/photo.png"))).toEqual([
|
|
99
|
+
"/Users/me/Pictures/photo.png",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("percent-decodes spaces inside `file://` URLs", () => {
|
|
104
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/My%20Pictures/photo.png"))).toEqual([
|
|
105
|
+
"/Users/me/My Pictures/photo.png",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
91
109
|
it("extracts explicit non-image paths without classifying them as image paths", () => {
|
|
92
110
|
expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
|
|
93
111
|
expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
|
|
@@ -107,6 +125,68 @@ describe("CustomEditor bracketed path paste", () => {
|
|
|
107
125
|
});
|
|
108
126
|
});
|
|
109
127
|
|
|
128
|
+
describe("extractImagePathFromText (issue #3506)", () => {
|
|
129
|
+
it("returns the path when the text is a single image file path", () => {
|
|
130
|
+
expect(extractImagePathFromText("/tmp/screenshot.png")).toBe("/tmp/screenshot.png");
|
|
131
|
+
expect(extractImagePathFromText("/Users/me/Pictures/photo.jpeg")).toBe("/Users/me/Pictures/photo.jpeg");
|
|
132
|
+
expect(extractImagePathFromText("C:\\Users\\me\\img.gif")).toBe("C:\\Users\\me\\img.gif");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ignores surrounding whitespace from a clipboard read", () => {
|
|
136
|
+
expect(extractImagePathFromText(" /tmp/photo.webp\n")).toBe("/tmp/photo.webp");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns undefined for a bare filename (no explicit directory)", () => {
|
|
140
|
+
// Mirrors the bracketed-paste contract: a bare `.png` filename is
|
|
141
|
+
// almost always a project-relative reference the user wants as text,
|
|
142
|
+
// not a clipboard-anchored attachment.
|
|
143
|
+
expect(extractImagePathFromText("icon.png")).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns undefined for non-image extensions", () => {
|
|
147
|
+
expect(extractImagePathFromText("/tmp/report.csv")).toBeUndefined();
|
|
148
|
+
expect(extractImagePathFromText("/tmp/notes.txt")).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns undefined when the text contains anything beyond a single path", () => {
|
|
152
|
+
expect(extractImagePathFromText("see /tmp/screenshot.png")).toBeUndefined();
|
|
153
|
+
expect(extractImagePathFromText("/tmp/a.png /tmp/b.png")).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns undefined for empty/whitespace-only input", () => {
|
|
157
|
+
expect(extractImagePathFromText("")).toBeUndefined();
|
|
158
|
+
expect(extractImagePathFromText(" ")).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("decodes a `file://` URL to its filesystem path", () => {
|
|
162
|
+
expect(extractImagePathFromText("file:///Users/me/Pictures/photo.png")).toBe("/Users/me/Pictures/photo.png");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("recovers a single anchored image path containing unescaped spaces (macOS screenshot name)", () => {
|
|
166
|
+
const macScreenshot = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png";
|
|
167
|
+
expect(extractImagePathFromText(macScreenshot)).toBe(macScreenshot);
|
|
168
|
+
expect(extractImagePathFromText("~/Pictures/Cleanshot 2026-06-25 at 12.00.png")).toBe(
|
|
169
|
+
"~/Pictures/Cleanshot 2026-06-25 at 12.00.png",
|
|
170
|
+
);
|
|
171
|
+
expect(extractImagePathFromText("C:\\Users\\me\\My Pictures\\img with space.jpg")).toBe(
|
|
172
|
+
"C:\\Users\\me\\My Pictures\\img with space.jpg",
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not hijack prose that happens to contain a path-shaped fragment", () => {
|
|
177
|
+
// The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
|
|
178
|
+
// non-anchored prefix ("see ...") never triggers it.
|
|
179
|
+
expect(extractImagePathFromText("see /Users/me/Desktop/Screenshot 1.png")).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("extractPastePathsFromText", () => {
|
|
184
|
+
it("delegates to the same logic the bracketed variant uses for path detection", () => {
|
|
185
|
+
expect(extractPastePathsFromText("/tmp/a.png /tmp/b.png")).toEqual(["/tmp/a.png", "/tmp/b.png"]);
|
|
186
|
+
expect(extractPastePathsFromText("just text")).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
110
190
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
111
191
|
beforeAll(async () => {
|
|
112
192
|
await initTheme();
|