Initialize self-scoped Subject conversation permissions and publish async chat profile

This commit is contained in:
2026-10-31 12:21:52 +09:00
parent 7252e61a23
commit 807a476c88
41 changed files with 1709 additions and 241 deletions
@@ -43,6 +43,12 @@ const memoryPageCursor = "fixture-memory-page-2";
const changePageCursor = "fixture-change-page-2";
const createdSubjectIds = new Set<string>();
const subjectCreateRequests: unknown[] = [];
type SubjectCreateInput = { operation_id: string; role: string; behavior_md: string };
const subjectCreateReceipts = new Map<
string,
{ request: SubjectCreateInput; subject: ReturnType<typeof subject> }
>();
const subjectCreateOutcomes: { outcome: string; subject_id?: string }[] = [];
let createdSubjectCount = 0;
const paragraphs = Array.from(
@@ -390,6 +396,21 @@ async function staticResponse(pathname: string): Promise<Response> {
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
// 合成HTTPの閲覧主体。実Backendの認証や権限付与の証拠にはしない。
if (url.pathname === "/api/auth/whoami") {
return json({
authentication_reference: "memory-review-fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
user_id: "fixture-user",
account_id: "owner",
handle: "fixture",
display_name: "Synthetic Memory owner",
auth_method: "browser_session",
},
});
}
if (url.pathname === "/api/hosting/access") return json({ is_admin: false });
if (permissionsMode) {
const response = await permissionsFixture(request);
if (response) return response;
@@ -397,6 +418,14 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
if (url.pathname === "/fixture/subject-create-requests") {
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
}
if (url.pathname === "/fixture/subject-create-evidence") {
return json({
authority: "synthetic-http-NOT-real-Backend",
requests: subjectCreateRequests,
outcomes: subjectCreateOutcomes,
created: subjects.filter((item) => createdSubjectIds.has(item.id)),
});
}
if (url.pathname === "/api/workspaces") {
return json([{
workspace_id: workspaceId,
@@ -488,22 +517,47 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
if (url.pathname === subjectsPath) {
if (request.method === "POST") {
const payload = await request.json().catch(() => null) as {
operation_id?: unknown;
role?: unknown;
behavior_md?: unknown;
} | null;
subjectCreateRequests.push(payload);
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
const reject = (message: string, status = 400) => {
subjectCreateOutcomes.push({ outcome: `rejected-${status}` });
return json({ error: status === 409 ? "Conflict" : "Bad Request", message }, status);
};
if (
!payload || typeof payload.operation_id !== "string" ||
payload.operation_id.trim().length === 0
) return reject("subject create operation_id is required");
if (typeof payload.role !== "string" || payload.role.trim().length === 0) {
return reject("subject role must not be empty");
}
if (payload.role === "Permission denied") {
subjectCreateOutcomes.push({ outcome: "rejected-403" });
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
}
if (typeof payload.behavior_md !== "string") {
return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400);
return reject("subject behavior must be a string");
}
const input: SubjectCreateInput = {
operation_id: payload.operation_id,
role: payload.role,
behavior_md: payload.behavior_md,
};
const receipt = subjectCreateReceipts.get(input.operation_id);
if (receipt) {
if (
receipt.request.role !== input.role || receipt.request.behavior_md !== input.behavior_md
) return reject("operation_id already used with different input", 409);
subjectCreateOutcomes.push({ outcome: "replay", subject_id: receipt.subject.id });
return json(receipt.subject, 201);
}
createdSubjectCount += 1;
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
const created = subject(id, payload.role, "active", "fingerprint-empty", payload.behavior_md);
const created = subject(id, input.role, "active", "fingerprint-empty", input.behavior_md);
subjectCreateReceipts.set(input.operation_id, { request: input, subject: created });
subjectCreateOutcomes.push({ outcome: "created", subject_id: id });
createdSubjectIds.add(id);
subjects.unshift(created);
return json(created, 201);
@@ -482,11 +482,25 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
]);
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
.json();
const operationIds = createEvidence.requests.map((request: { operation_id: string }) =>
request.operation_id
);
for (const operationId of operationIds) {
assert(
typeof operationId === "string" &&
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
operationId,
),
"Each new creation attempt must carry a UUID operation_id",
);
}
assert(operationIds[0] !== operationIds[1], "A resolved rejection must allow a new attempt");
assertEquals(createEvidence, {
count: 2,
requests: [
{ role: "Permission denied", behavior_md: "" },
{ operation_id: operationIds[0], role: "Permission denied", behavior_md: "" },
{
operation_id: operationIds[1],
role: " Release steward ",
behavior_md: "Challenge unsupported assumptions before release.",
},
@@ -0,0 +1,444 @@
#!/usr/bin/env -S deno run -A
/** 実Webの作成・再送を合成HTTP境界で検証する。実Backendの保存・認可は証明しない。 */
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join, relative, resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { sha256File } from "../src/artifacts.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
const [buildArg, runId, modeArg] = Deno.args;
const mode = modeArg ?? (runId?.startsWith("before") ? "before" : "after");
if (
!buildArg || !runId || !/^[a-zA-Z0-9._-]+$/.test(runId) || !["before", "after"].includes(mode)
) {
throw new Error(
"usage: subject_creation_review.ts <actual-build-path> <unique-run-id> [before|after]",
);
}
const buildRoot = resolve(buildArg);
for (const name of ["index.html", "_app/immutable"]) {
await Deno.stat(join(buildRoot, name)).catch(() => {
throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`);
});
}
const output = join(root, "target/web-ux/t806", runId);
await Deno.mkdir(dirname(output), { recursive: true });
await Deno.mkdir(output); // 既存runの画像や判断を上書きしない。
async function git(args: string[]) {
const result = await new Deno.Command("git", { args, cwd: root, stdout: "piped" }).output();
return result.success ? new TextDecoder().decode(result.stdout).trim() : null;
}
async function inventory(path: string): Promise<{ path: string; sha256: string }[]> {
const files: { path: string; sha256: string }[] = [];
for await (const entry of Deno.readDir(path)) {
const child = join(path, entry.name);
if (entry.isDirectory) files.push(...await inventory(child));
else if (entry.isFile) {
files.push({ path: relative(buildRoot, child), sha256: await sha256File(child) });
}
}
return files.sort((a, b) => a.path.localeCompare(b.path));
}
const buildFiles = await inventory(buildRoot);
const sourceStatus = await git(["status", "--porcelain"]);
const manifest = {
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend",
mode,
buildRoot,
buildFiles,
source: {
commitSha: await git(["rev-parse", "HEAD"]),
dirty: sourceStatus === null ? null : sourceStatus.length > 0,
status: sourceStatus,
captureSources: await Promise.all([
"tools/web-ux/browser-tests/subject_creation_review.ts",
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
"tools/web-ux/browser-tests/memory_document_harness.ts",
].map(async (path) => ({ path, sha256: await sha256File(join(root, path)) }))),
buildProvenance:
"parent-supplied build; checkout metadata is capture-time, not a build attestation",
},
visualVerdict: "UNREVIEWED — parent must ViewImage and inspect review-context.json",
status: "running",
browser: { name: "chromium", version: "" },
expectedFaults: [
"Per width/theme: first POST reply aborted only after synthetic persistence (HTTP 201)",
...(mode === "after"
? ["Per width/theme: second POST intentionally returns HTTP 403 before fixture"]
: []),
],
baselineLimit: mode === "before"
? "Capture only. Missing operation_id is added ONLY to fixture transport for synthetic persistence; original browser body is recorded. No replay or disabled-input success asserted."
: null,
captures: [] as unknown[],
scenarios: [] as unknown[],
failures: [] as string[],
};
async function saveManifest() {
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
}
type Fault = {
kind: string;
message: string;
url?: string;
method?: string;
expected: boolean;
};
function observe(page: Page, createUrl: string) {
const faults: Fault[] = [];
let documentStatus: number | null = null;
let allowAbort = false;
let allow403 = false;
page.on(
"pageerror",
(e) => faults.push({ kind: "pageerror", message: e.stack ?? e.message, expected: false }),
);
page.on("console", (e) => {
if (!["warning", "error"].includes(e.type())) return;
const url = e.location().url;
// 意図した同一endpointのネットワーク診断だけを許容し、pageerrorは常に失敗にする。
const expected = url === createUrl && (
(allowAbort && /Failed to load resource: net::ERR_FAILED/.test(e.text())) ||
(allow403 && /Failed to load resource:.*403/.test(e.text()))
);
faults.push({ kind: `console-${e.type()}`, message: e.text(), url, expected });
});
page.on("requestfailed", (r) =>
faults.push({
kind: "requestfailed",
message: r.failure()?.errorText ?? "request failed",
url: r.url(),
method: r.method(),
expected: allowAbort && r.url() === createUrl && r.method() === "POST" &&
r.failure()?.errorText === "net::ERR_FAILED",
}));
page.on("response", (r) => {
if (r.request().isNavigationRequest()) documentStatus = r.status();
if (r.status() >= 400) {
faults.push({
kind: "http",
message: `HTTP ${r.status()}`,
url: r.url(),
method: r.request().method(),
expected: allow403 && r.url() === createUrl && r.request().method() === "POST" &&
r.status() === 403,
});
}
});
return {
faults,
documentStatus: () => documentStatus,
expectAbort: () => {
allowAbort = true;
},
expect403: () => {
allow403 = true;
},
};
}
async function controls(page: Page) {
const role = page.getByRole("textbox", { name: "Role", exact: true });
const behavior = page.getByRole("textbox", { name: /Behavior/ });
if (!await role.count()) return null;
return {
role: await role.inputValue(),
behavior: await behavior.inputValue(),
roleDisabled: await role.isDisabled(),
behaviorDisabled: await behavior.isDisabled(),
submitting: await page.locator("#subject-create-panel").getAttribute("aria-busy"),
};
}
async function capture(page: Page, observation: ReturnType<typeof observe>, id: string) {
const image = join(output, `${id}.png`);
const snapshot = join(output, `${id}.aria.txt`);
await page.screenshot({ path: image });
await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot());
manifest.captures.push({
id,
route: new URL(page.url()).pathname,
viewport: page.viewportSize(),
theme: await page.evaluate(() =>
(globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
),
documentStatus: observation.documentStatus(),
visibleAlerts: await page.getByRole("alert").allTextContents(),
controls: await controls(page),
focus: await page.locator(":focus").evaluateAll((elements) =>
elements.map((e) => ({ tag: e.tagName, text: e.textContent, id: e.id }))
),
mainBounds: await page.locator("main").evaluate((e) => ({
width: e.clientWidth,
scrollTop: e.scrollTop,
})),
image,
imageSha256: await sha256File(image),
accessibilitySnapshot: snapshot,
accessibilitySha256: await sha256File(snapshot),
faults: [...observation.faults],
unexpectedFaults: observation.faults.filter((f) => !f.expected),
reviewed: false,
});
await saveManifest();
await assertNoPageWideOverflow(page);
assertEquals(observation.documentStatus(), 200, `${id}: document must render successfully`);
}
const roleText = " Release steward — evidence and uncertainty ";
const behaviorText =
"Challenge unsupported assumptions before release.\nAsk before irreversible actions; preserve the exact pending request.\nEvidence: https://example.com/review/subject-creation";
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
async function assertPending(page: Page) {
assertEquals(await controls(page), {
role: roleText,
behavior: behaviorText,
roleDisabled: true,
behaviorDisabled: true,
submitting: "false",
});
assert(await page.getByRole("button", { name: "Create Subject", exact: true }).isEnabled());
}
async function reopen(page: Page) {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.locator("#subject-create-panel").waitFor({ state: "detached" });
assert(
await page.getByRole("button", { name: "New Subject", exact: true }).evaluate((e) =>
e === e.ownerDocument.activeElement
),
);
await page.getByRole("button", { name: "New Subject", exact: true }).click();
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
}
const browser = await chromium.launch({ headless: true });
manifest.browser.version = browser.version();
try {
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const prefix = `${theme}-${width}`;
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const createUrl = `${base}/api/w/memory-review/subjektiv/subjects`;
const owned = await startOwnedProcesses(
[{
id: `subject-create-${prefix}`,
command: Deno.execPath(),
cwd: join(root, "tools/web-ux"),
args: [
"run",
"-A",
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
String(port),
buildRoot,
"3",
],
readyUrl: `${base}/health`,
}],
join(output, "scenario.json"),
join(output, `logs-${prefix}`),
[],
);
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
const page = await context.newPage();
page.setDefaultTimeout(15_000);
const observation = observe(page, createUrl);
const requests: unknown[] = [];
const transportOverrides: unknown[] = [];
const scenario = {
prefix,
requests,
transportOverrides,
syntheticEvidence: null as unknown,
faults: observation.faults,
};
manifest.scenarios.push(scenario);
try {
await page.goto(`${base}/w/memory-review/memory`);
await page.getByRole("heading", { name: "Subjects", level: 1, exact: true }).waitFor();
await page.getByRole("button", { name: "New Subject", exact: true }).click();
const role = page.getByRole("textbox", { name: "Role", exact: true });
const behavior = page.getByRole("textbox", { name: /Behavior/ });
await role.fill(roleText);
await behavior.fill(behaviorText);
await role.focus();
await capture(page, observation, `${prefix}-normal-form`);
await page.keyboard.press("Tab");
assert(await behavior.evaluate((e) => e === e.ownerDocument.activeElement));
await page.keyboard.press("Tab");
assert(
await page.getByRole("button", { name: "Create Subject", exact: true }).evaluate((e) =>
e === e.ownerDocument.activeElement
),
);
await capture(page, observation, `${prefix}-normal-actions-keyboard`);
let attempt = 0;
await page.route(createUrl, async (route) => {
if (route.request().method() !== "POST") return await route.continue();
attempt += 1;
const body = route.request().postDataJSON();
requests.push(body);
if (attempt === 1) {
observation.expectAbort();
// 旧buildの比較撮影だけ、fixtureへ送るコピーにoperation_idを足す。
// 実browser要求はrequestsに未変更で保存し、旧UIの契約成功とは扱わない。
const bridged = mode === "before" && !body.operation_id;
const transport = bridged ? { ...body, operation_id: crypto.randomUUID() } : body;
if (bridged) {
transportOverrides.push({
attempt,
reason: "baseline-only fixture bridge",
body: transport,
});
}
const persisted = await route.fetch({ postData: JSON.stringify(transport) });
assertEquals(
persisted.status(),
201,
"Fault must follow synthetic persistence, not validation rejection",
);
await persisted.dispose();
await route.abort("failed");
} else if (attempt === 2 && mode === "after") {
observation.expect403();
await route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({
error: "Forbidden",
message: "synthetic later permission rejection",
}),
});
} else await route.continue();
});
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
await page.getByRole("alert").filter({ hasText: /unknown|may have been created/i })
.waitFor();
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
await page.getByRole("alert").scrollIntoViewIfNeeded();
await capture(page, observation, `${prefix}-unknown-after-persisted-response-loss`);
await role.scrollIntoViewIfNeeded();
await capture(page, observation, `${prefix}-unknown-fields`);
if (mode === "after") {
await assertPending(page);
const first = requests[0] as { operation_id: string; role: string; behavior_md: string };
assert(uuid.test(first.operation_id), "UI must generate a UUID per creation attempt");
assertEquals(first, {
operation_id: first.operation_id,
role: roleText,
behavior_md: behaviorText,
});
await reopen(page);
await assertPending(page);
await page.getByRole("alert").scrollIntoViewIfNeeded();
await capture(page, observation, `${prefix}-cancel-reopen-pending`);
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
await page.getByRole("alert").filter({ hasText: /do not have permission/ }).waitFor();
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
assertEquals(
requests[1],
first,
"Retry after cancel/reopen must retain the complete request",
);
await assertPending(page);
await page.getByRole("alert").scrollIntoViewIfNeeded();
await capture(page, observation, `${prefix}-later-403-still-pending`);
await role.scrollIntoViewIfNeeded();
await capture(page, observation, `${prefix}-later-403-disabled-fields`);
await reopen(page);
await assertPending(page);
await page.getByRole("alert").scrollIntoViewIfNeeded();
await capture(page, observation, `${prefix}-later-403-reopened-pending`);
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
await page.waitForURL("**/memory/created-subject-0001");
await page.getByRole("heading", { name: roleText.trim(), level: 1, exact: true })
.waitFor();
assertEquals(
requests,
[first, first, first],
"All retries must use the same operation and full input",
);
const article = page.getByRole("article", { name: "User-managed Subject behavior" });
await article.waitFor();
assert((await article.textContent())?.includes("preserve the exact pending request."));
await page.getByText("No committed Memories yet.").waitFor();
await capture(page, observation, `${prefix}-recovered-actual-detail`);
}
scenario.syntheticEvidence = await (await fetch(`${base}/fixture/subject-create-evidence`))
.json();
const evidence = scenario.syntheticEvidence as {
created: { id: string; role: string; behavior_md: string }[];
requests: unknown[];
outcomes: unknown[];
};
assertEquals(evidence.created.length, 1, "Only one synthetic Subject may be created");
assertEquals(evidence.created[0].role, roleText);
assertEquals(evidence.created[0].behavior_md, behaviorText);
if (mode === "after") {
assertEquals(evidence.requests, [requests[0], requests[0]]);
assertEquals(evidence.outcomes, [
{ outcome: "created", subject_id: "created-subject-0001" },
{ outcome: "replay", subject_id: "created-subject-0001" },
]);
assertEquals(observation.faults.filter((f) => f.kind === "http" && f.expected).length, 1);
}
assertEquals(
observation.faults.filter((f) => f.kind === "requestfailed" && f.expected).length,
1,
);
assertEquals(
observation.faults.filter((f) => !f.expected),
[],
`${prefix}: unexpected browser faults`,
);
await Deno.writeTextFile(
join(output, `${prefix}-synthetic-http-evidence.json`),
JSON.stringify(scenario, null, 2),
);
} catch (e) {
manifest.failures.push(
`${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`,
);
await capture(page, observation, `${prefix}-failure`).catch((captureError) =>
manifest.failures.push(`Failure capture: ${captureError}`)
);
throw e;
} finally {
try {
scenario.syntheticEvidence = await fetch(`${base}/fixture/subject-create-evidence`)
.then((response) => response.json()).catch(() => scenario.syntheticEvidence);
await Deno.writeTextFile(
join(output, `${prefix}-synthetic-http-evidence.json`),
JSON.stringify(scenario, null, 2),
);
} finally {
try {
await context.close();
} finally {
await stopOwnedProcesses(owned);
await saveManifest();
}
}
}
}
}
assertEquals(
await inventory(buildRoot),
buildFiles,
"Parent build changed during the capture matrix",
);
manifest.status = mode === "before"
? "baseline-capture-only-visual-unreviewed"
: "browser-assertions-passed-visual-unreviewed";
} catch (e) {
manifest.status = "failed";
throw e;
} finally {
await browser.close();
await saveManifest();
console.log(join(output, "review-context.json"));
}