diff --git a/tests/runner-e2e/chat-catalog-domains/external-company-secret-route.test.ts b/tests/runner-e2e/chat-catalog-domains/external-company-secret-route.test.ts
new file mode 100644
index 00000000000..fdbe273afd4
--- /dev/null
+++ b/tests/runner-e2e/chat-catalog-domains/external-company-secret-route.test.ts
@@ -0,0 +1,21 @@
+import {expect,it} from "vitest";
+import {chromium} from "@playwright/test";
+import {companySecretSetupURL} from "./external-company-secret.js";
+const origin="https://paperclip.example",route="/RUN/company/settings/secrets";
+const body="I can't enter the key value myself — company secret values are board-only secure entry, and I won't ask you to paste a secret into chat.\n\nTo add it:\n1. Open `/RUN/company/settings/secrets`.\n2. Click **New secret**.\n3. Use name e.g. **Deployment API key**, env key **DEPLOYMENT_API_KEY**, and enter the deployment key value there.\n4. Save it, then tell me the name/key you used (not the value).\n\nOnce it exists I'll add a `DEPLOYMENT_API_KEY` secret reference to Morgan's agent environment and verify the reference is in place. Saving the secret only stores it; Morgan gets it on its next run after the binding.";
+it("admits the actual4587 displayed inline route and rejects absent, foreign, cross-company or hidden routes in offline Chromium",async()=>{
+ const browser=await chromium.launch({headless:true}),page=await browser.newPage();
+ const render=async(text:string,html?:string,id="native")=>{await page.setContent(``);};
+ try{
+ await render(route);expect(await companySecretSetupURL(page,{body,commentId:"native"},origin,"RUN",1000)).toBe(origin+route);
+ await render(route,`Open Secrets`);expect(await companySecretSetupURL(page,{body,commentId:"native"},origin,"RUN",1000)).toBe(origin+route);
+ for(const target of ["/OTHER/company/settings/secrets","https://foreign.example"+route,"/api/companies/company/secrets"]){await render(target);expect(await companySecretSetupURL(page,{body:body.replace(route,target),commentId:"native"},origin,"RUN",1000)).toBeUndefined();}
+ await render(route,`${route}`);expect(await companySecretSetupURL(page,{body,commentId:"native"},origin,"RUN",1000)).toBeUndefined();
+ await render("No route");expect(await companySecretSetupURL(page,{body,commentId:"native"},origin,"RUN",1000)).toBeUndefined();
+ for(const html of [`${route}`,`${route}`,`${route}`]){await render(route,html);expect(await companySecretSetupURL(page,{body,commentId:"native"},origin,"RUN",1000)).toBeUndefined();}
+ await render(route,undefined,"other-actor");await expect(companySecretSetupURL(page,{body,commentId:"native"},origin,"RUN",1000)).rejects.toThrow();
+ await render(route);expect(await companySecretSetupURL(page,{body:body.replace(route,"missing"),commentId:"native"},origin,"RUN",1000)).toBeUndefined();
+ expect(await companySecretSetupURL(page,{body:`Open ${route}`,commentId:"native"},origin,"RUN",1000)).toBeUndefined();
+ expect(await companySecretSetupURL(page,{body:body+" Paste the key here in chat.",commentId:"native"},origin,"RUN",1000)).toBeUndefined();
+ }finally{await browser.close();}
+},30000);
diff --git a/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts b/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts
index b79e6fe31ae..43f39e080ca 100644
--- a/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts
+++ b/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts
@@ -1,6 +1,6 @@
import { chatCatalogNativePhaseRemainingBudget, chatCatalogOriginalPhaseDeadline } from "../chat-catalog-contract.js";
import { randomBytes } from "node:crypto";
-import { expect } from "@playwright/test";
+import { expect, type Page } from "@playwright/test";
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
import type { CompanySecret, CompanySecretUsageBinding } from "../../../packages/shared/src/types/secrets.js";
import { definition,root,checks,externalObservation,observeExternal,verifyRenderedReply } from "./external-support.js";
@@ -40,6 +40,20 @@ export function deploymentSecretGuidance(text:string){
&&!text.split(/[.!;\n]+|\bbut\b/i).some(clause=>/\b(?:I(?:['’]ve| have)?|we(?:['’]ve| have)?)\s+(?:already\s+)?(?:added|created|bound|configured)\b/i.test(clause))
&&!text.split(/[.!;\n]+/).some(clause=>/\b(?:bind|assign|add)\b[^.!;\n]{0,45}\b(?:all agents|every agent|CEO|Riley)\b/i.test(clause)&&!/(?:do not|don.t|never|avoid)\s+(?:bind|assign|add)/i.test(clause));
}
+export async function companySecretSetupURL(page:Page,reply:{body:string;commentId?:string},origin:string,prefix:string,timeout:number){
+ if(!reply.commentId||!deploymentSecretGuidance(reply.body))return;
+ const comment=page.locator(`[id=${JSON.stringify(`comment-${reply.commentId}`)}]`);
+ await comment.waitFor({state:"visible",timeout});
+ const displayed=await comment.evaluate(root=>{
+ const shown=(node:Element)=>{for(let element:Element|null=node;element;element=element.parentElement){const style=getComputedStyle(element);if(style.display==="none"||style.visibility==="hidden"||style.visibility==="collapse"||Number(style.opacity)===0)return false;}return true;};
+ const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT),parts:string[]=[];
+ for(let node=walker.nextNode();node;node=walker.nextNode())if(node.parentElement&&shown(node.parentElement)&&!node.parentElement.closest("a[href]"))parts.push(node.textContent??"");
+ return {text:parts.join("\n"),links:Array.from(root.querySelectorAll("a[href]")).filter(shown).map(node=>node.href)};
+ },undefined,{timeout});
+ const supported=(candidate:string)=>{try{const url=new URL(candidate,origin);return url.origin===origin&&url.pathname===`/${prefix}/company/settings/secrets`&&!url.username&&!url.password&&!url.search&&!url.hash?url.href:undefined;}catch{return;}};
+ const routes=(text:string)=>(text.match(/https?:\/\/[^\s`<>"']+|(?"']+/g)??[]).map(value=>value.replace(/[.,;!?\])}]+$/,"")).map(supported).filter((value):value is string=>!!value);
+ return routes(reply.body).find(route=>displayed.links.some(link=>supported(link)===route)||routes(displayed.text).includes(route));
+}
export function companySecretQuestionHandoff(value:unknown,b:SecretQuestionBinding,status:"pending"|"answered"="pending"){
const bound=boundSecretQuestion(value,b,status)??boundSecretQuestion(value,b,status,"company-environment-name");if(!bound)return;
const {row,question:q,questionSet:qs,text,payloadHash}=bound;
@@ -125,7 +139,9 @@ export const companySecretExternalCases=[definition("secrets.configure-company-s
remaining();
const beforeHuman=await ctx.input.api.get(`${root(ctx)}/secrets`);
state.secretCountBeforeHuman=beforeHuman.length;if(!Array.isArray(state.controllerSecretBaseline)||beforeHuman.length!==state.controllerSecretBaseline.length||!companySecretControllersUnchanged(beforeHuman,state.controllerSecretBaseline))throw new Error("Secret catalog changed before authorized human entry");
- const path=`/${ctx.input.fixtures.company.issuePrefix}/company/settings/secrets`,href=initial.links.find(link=>{try{const url=new URL(link,initial.origin);return url.origin===initial.origin&&url.pathname===path;}catch{return false;}});if(!href&&!question)throw new Error("Actual reply must link the supported secret management surface");
+ const prefix=ctx.input.fixtures.company.issuePrefix;if(!prefix)throw new Error("Company secret setup requires the current company route prefix");
+ const path=`/${prefix}/company/settings/secrets`,href=commentHandoff?await companySecretSetupURL(ctx.input.page,reply!,initial.origin,prefix,remaining()):undefined;
+ if(!href&&!question)throw new Error("Actual visible reply must direct the supported company secret management surface");
const receipt=await privateBrowserEntry({mode:"company-secret-create",companyId:ctx.input.fixtures.company.id,baseURL:initial.origin,setupURL:new URL(href??path,initial.origin).href,providerURL:"",credential:values.get(ctx)!,cookies:await ctx.input.page.context().cookies(initial.origin)});
Object.assign(state,receipt,{secureBrowserUntraced:receipt.processIsolation&&receipt.testRunnerAttached===false&&receipt.apiStepRecorder===false&&receipt.tracingEnabled===false&&receipt.videoEnabled===false,operatorChannel:"private-browser-company-secret-and-public-browser-binding"});
const sourceURL=ctx.input.page.url();await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/agents/${state.morganId}/configuration`,{waitUntil:"domcontentloaded"});
diff --git a/tests/runner-e2e/chat-catalog-domains/external-team-conditional.test.ts b/tests/runner-e2e/chat-catalog-domains/external-team-conditional.test.ts
index 480025c80c4..c842ac67227 100644
--- a/tests/runner-e2e/chat-catalog-domains/external-team-conditional.test.ts
+++ b/tests/runner-e2e/chat-catalog-domains/external-team-conditional.test.ts
@@ -296,3 +296,21 @@ it("distinguishes the actual preserved local skill name from a contained catalog
o.phaseReplies[0]!.body+=" Launch Checklist is now installed under another slug.";
expect(gradeTeamConditions(o,"initial").passed).toBe(false);
});
+
+it("rejects present unavailability of a verified recovered skill", () => {
+ for (const state of ["available", "usable"]) {
+ const r=recovered(); r.o.phaseReplies[1]!.body=`Launch Checklist is still not ${state}.`;
+ expect(gradeTeamConditions(r.o).passed).toBe(false);
+ }
+});
+it("rejects possessed and bare installation claims while the required catalog skill remains missing", () => {
+ for(const claim of ["We have Launch Checklist installed.", "Launch Checklist installed under another slug."]) {
+ const o=valid();o.phaseReplies[0]!.body="The existing CTO was preserved and the imported role is CTO 2. Launch Checklist's slug is occupied. "+claim;
+ expect(gradeTeamConditions(o,"initial").passed).toBe(false);
+ }
+});
+
+it("does not treat an earlier team installation as recovery of a named skill reported historically uninstalled", () => {
+ const r=recovered();r.o.phaseReplies[0]!.body="The existing CTO was preserved and the imported role is CTO 2. The team installed successfully, but Launch Checklist was not installed.";
+ expect(gradeTeamConditions(r.o,"initial").passed).toBe(false);
+});
diff --git a/tests/runner-e2e/chat-catalog-domains/external-team-conditional.ts b/tests/runner-e2e/chat-catalog-domains/external-team-conditional.ts
index c0a4f634c80..a6260848f62 100644
--- a/tests/runner-e2e/chat-catalog-domains/external-team-conditional.ts
+++ b/tests/runner-e2e/chat-catalog-domains/external-team-conditional.ts
@@ -193,13 +193,21 @@ function negatedTeamRecovery(before: string): boolean {
return /\b(?:not|never|isn.t|aren.t|wasn.t|weren.t|hasn.t|haven.t)\s+(?:(?:be|been|yet|currently|still|successfully|fully|completely)\s+){0,2}$/i.test(before);
}
function truthfulTeamRecovery(reply: string, skill: Pick): boolean {
- return namedTeamSkillClauses(reply, skill).some(clause => [...clause.matchAll(/\b(?:installed|available|resolved|recovered)\b/gi)].some(match =>
- !negatedTeamRecovery(clause.slice(0, match.index))))
+ const names = [skill.slug, skill.slug.replaceAll("-", " "), skill.name].map(name => name.toLowerCase());
+ return namedTeamSkillClauses(reply, skill).some(clause => {
+ const namedAt = names.map(name => clause.toLowerCase().indexOf(name)).filter(index => index >= 0);
+ const firstName = namedAt.length ? Math.min(...namedAt) : -1;
+ return [...clause.matchAll(/\b(?:installed|available|resolved|recovered)\b/gi)].some(match => {
+ const targetAfter = clause.slice(match.index + match[0].length).replace(/^\s+(?:the\s+)?[*_`]*/i, "").toLowerCase();
+ return (firstName < 0 || match.index >= firstName || match[0].toLowerCase() === "installed" && names.some(name => targetAfter.startsWith(name)))
+ && !negatedTeamRecovery(clause.slice(0, match.index));
+ });
+ })
&& !claimsRecoveredSkillMissing(reply, skill);
}
function claimsRecoveredSkillMissing(reply: string, skill: Pick): boolean {
return namedTeamSkillClauses(reply, skill, true).some(clause =>
- [...clause.matchAll(/\b(?:not (?:(?:successfully|fully|completely)\s+){0,2}installed|uninstalled|missing|unavailable|blocked)\b/gi)].some(match => {
+ [...clause.matchAll(/\b(?:not (?:(?:successfully|fully|completely)\s+){0,2}(?:installed|available|usable)|uninstalled|missing|unavailable|blocked)\b/gi)].some(match => {
const before = clause.slice(0, match.index);
return !/\b(?:was|were|had been)\s+(?:(?:previously|initially|formerly|earlier|still)\s+){0,2}$/i.test(before)
&& (match[0].toLowerCase().startsWith("not ") || /\b(?:is|are|remains?|still)\s+(?:currently\s+)?$/i.test(before));
@@ -272,7 +280,7 @@ export function truthfulTeamPartial(
});
const subjects = [skill.slug, skill.slug.replaceAll("-", " "), skill.name]
.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
- const currentRecovery = new RegExp("(?:\\b(?:" + subjects + ")[*_`]*|^(?:it|(?:this|that) skill))\\s+(?:skill\\s+)?(?:is|has been)\\s+(?:(?:now|already|currently)\\s+)?(?:installed|available|resolved|recovered)\\b", "i");
+ const currentRecovery = new RegExp("(?:\\b(?:" + subjects + ")[*_`]*|^(?:it|(?:this|that) skill))\\s+(?:skill\\s+)?(?:(?:is|has been)\\s+)?(?:(?:now|already|currently)\\s+)?(?:installed|available|resolved|recovered)\\b", "i");
const installedObject = new RegExp("\\binstalled\\s+(?:the\\s+)?[*_`]*(?:" + subjects + ")\\b", "gi");
return reported && !falseFull && !namedTeamSkillClauses(reply, skill, true).some(clause =>
currentRecovery.test(clause) || [...clause.matchAll(installedObject)].some(claim => !negatedTeamRecovery(clause.slice(0, claim.index))));
diff --git a/tests/runner-e2e/chat-catalog-domains/integrator-skills.test.ts b/tests/runner-e2e/chat-catalog-domains/integrator-skills.test.ts
index 18d9ddf9278..5422d6a3952 100644
--- a/tests/runner-e2e/chat-catalog-domains/integrator-skills.test.ts
+++ b/tests/runner-e2e/chat-catalog-domains/integrator-skills.test.ts
@@ -100,3 +100,18 @@ it("rejects a universal execution restriction despite a separately stated viewpo
it("keeps a quantified noncurrent artifact restriction separate from a universal review waiver", () => {
expect(preservesWebsiteChecklist(`${websiteChecklist.join("\n")}; do not perform the check on any cached build.`)).toBe(true);
});
+
+it("rejects necessity waivers bound to the required viewport check", () => {
+ for (const waiver of ["The 375px viewport check without horizontal scrolling is unnecessary.", "The 375px viewport check without horizontal scrolling is not necessary.", "There is no requirement to test a 375px viewport without horizontal scrolling."])
+ expect(preservesWebsiteChecklist(websiteChecklist.slice(0, 3).join("\n") + "\n" + waiver)).toBe(false);
+});
+it("preserves the full viewport requirement beside cached production and comma-qualified restrictions", () => {
+ const original=websiteChecklist.join("\n");
+ expect(preservesWebsiteChecklist(`${original}; do not perform the check on a cached production build.`)).toBe(true);
+ expect(preservesWebsiteChecklist(`${original.slice(0,-1)}, but do not perform the check on a cached build.`)).toBe(true);
+ expect(preservesWebsiteChecklist(`${original}, not on any cached build using any browser.`)).toBe(true);
+});
+
+it("keeps a required viewport check beside a protective prohibition of horizontal scrolling", () => {
+ expect(preservesWebsiteChecklist(`${websiteChecklist.join("\n")} Do not allow horizontal scrolling at the 375px viewport.`)).toBe(true);
+});
diff --git a/tests/runner-e2e/chat-catalog-domains/integrator-skills.ts b/tests/runner-e2e/chat-catalog-domains/integrator-skills.ts
index 1e5e4b9cf48..7c2758e7418 100644
--- a/tests/runner-e2e/chat-catalog-domains/integrator-skills.ts
+++ b/tests/runner-e2e/chat-catalog-domains/integrator-skills.ts
@@ -19,17 +19,19 @@ function websiteChecklistChecks(markdown: string): boolean[] {
const forbidden = /\b(?:skip|omit|ignore|don't|do not|need not|not required|optional)\b/i;
const mobileRequirements = [/\b375\s*(?:px|pixels?)?\b/i, /\b(?:viewport|wide|width)\b/i, /(?:without horizontal (?:scrolling|overflow)|no horizontal (?:scrolling|overflow)|(?:must not|does not|doesn't) (?:scroll|overflow) horizontally|(?:confirm|check|verify)[^.!?]{0,70}whether[^.!?]{0,70}horizontal scrolling)/i];
const mobileWaiver = (text: string) => {
- const clauses = text.split(/[.!?;\n]+/);
+ const clauses = text.split(/[.!?;\n]+|,\s*(?:but\s+)?(?=(?:don't|do not|never|avoid|no need to)\b)/i);
const affirmative = clauses.some(clause => mobileRequirements.every(pattern => pattern.test(clause))
&& !/\b(?:don't|do not|never|avoid|no need to|need not|skip|omit|ignore|optional|not required)\b/i.test(clause));
return /\b(?:skip|omit|ignore|need not|not required|optional)\b/i.test(text)
+ || clauses.some(clause => /\b(?:375\s*(?:px|pixels?)?|viewport|check|test|review)\b/i.test(clause)
+ && /\b(?:unnecessary|not necessary|no requirement to)\b/i.test(clause))
|| clauses.some(clause => [...clause.matchAll(/\b(?:don't|do not|never|avoid|no need to)\s+/gi)].some(negation => {
const action = clause.slice(negation.index + negation[0].length).trimStart();
const target = /\b(?:375\s*(?:px|pixels?)?|viewport|check|test|review)\b/i.exec(action);
- const overflow = /\boverflow(?:ing)?\b/i.exec(action);
+ const overflow = /\b(?:overflow(?:ing)?|horizontal scroll(?:ing)?|scroll(?:ing)? horizontally)\b/i.exec(action);
const restriction = /\b(?:on|with|using|against)\s+(.+)$/i.exec(action)?.[1];
const qualified = !!restriction
- && /^(?:(?:a|an|the|any|all|every|this|that|these|those)\s+)?(?:cached|stale|outdated|previous|old|archived)\s+(?:build|version|snapshot|artifact|bundle)s?\b/i.test(restriction)
+ && /^(?:(?:a|an|the|any|all|every|this|that|these|those)\s+)?(?:cached|stale|outdated|previous|old|archived)\s+(?:production\s+)?(?:build|version|snapshot|artifact|bundle)s?\b/i.test(restriction)
&& !/\b(?:current|live|selected|375\s*(?:px|pixels?)?|viewport|mobile|width)\b/i.test(restriction);
return !!target && (!overflow || target.index < overflow.index) && !(affirmative && qualified);
}));