@01.works/visual-review 0.14.0 → 0.15.0

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/dist/cli.js CHANGED
@@ -139,6 +139,7 @@ function sanitizePayload(input, caps, forceTruncated = false) {
139
139
  const region = record(target.region);
140
140
  const inheritedTrust = record(root.trust);
141
141
  const state = {
142
+ redacted: inheritedTrust.redacted === true,
142
143
  truncated: forceTruncated || inheritedTrust.truncated === true
143
144
  };
144
145
  const source = sanitizeSourceLocation(root.source, caps, state);
@@ -152,7 +153,7 @@ function sanitizePayload(input, caps, forceTruncated = false) {
152
153
  const firstElement = elements[0];
153
154
  const sanitized = {
154
155
  schemaVersion: 3,
155
- trust: trustMetadata(false),
156
+ trust: trustMetadata(false, false),
156
157
  feedback: {
157
158
  id: sanitizeText(feedback.id, caps.id, state),
158
159
  status: sanitizeText(feedback.status, caps.status, state),
@@ -199,7 +200,7 @@ function sanitizePayload(input, caps, forceTruncated = false) {
199
200
  if (!(root.stale === null || typeof root.stale === "boolean")) {
200
201
  state.truncated = true;
201
202
  }
202
- sanitized.trust = trustMetadata(state.truncated);
203
+ sanitized.trust = trustMetadata(state.redacted, state.truncated);
203
204
  return sanitized;
204
205
  }
205
206
  function sanitizeTargetElements(input, caps, state) {
@@ -338,14 +339,64 @@ function sanitizePageUrl(value, limit, state) {
338
339
  if (parsed.username || parsed.password) {
339
340
  parsed.username = "";
340
341
  parsed.password = "";
341
- state.truncated = true;
342
+ state.redacted = true;
342
343
  }
344
+ if (redactSensitiveParameters(parsed.searchParams)) state.redacted = true;
345
+ if (parsed.hash && redactSensitiveFragment(parsed)) state.redacted = true;
343
346
  return sanitizeText(parsed.toString(), limit, state);
344
347
  } catch {
345
348
  state.truncated = true;
346
349
  return "unavailable";
347
350
  }
348
351
  }
352
+ var sensitiveUrlParameterParts = /* @__PURE__ */ new Set([
353
+ "auth",
354
+ "authorization",
355
+ "code",
356
+ "credential",
357
+ "invite",
358
+ "key",
359
+ "password",
360
+ "secret",
361
+ "session",
362
+ "sig",
363
+ "signature",
364
+ "token"
365
+ ]);
366
+ function isSensitiveUrlParameter(key) {
367
+ const normalized = key.toLowerCase();
368
+ if (normalized === "visual-review") return true;
369
+ const parts = normalized.split(/[-_.]/u);
370
+ return parts.some((part) => sensitiveUrlParameterParts.has(part)) || [...sensitiveUrlParameterParts].some((part) => normalized.endsWith(part));
371
+ }
372
+ function redactSensitiveParameters(parameters) {
373
+ const sensitiveKeys = new Set(
374
+ [...parameters.keys()].filter((key) => isSensitiveUrlParameter(key))
375
+ );
376
+ for (const key of sensitiveKeys) parameters.set(key, "REDACTED");
377
+ return sensitiveKeys.size > 0;
378
+ }
379
+ function redactSensitiveFragment(url) {
380
+ const fragment = url.hash.slice(1);
381
+ const route = /^(?:!\/|\/)/u.test(fragment);
382
+ const queryIndex = fragment.indexOf("?");
383
+ if (route && queryIndex >= 0) {
384
+ const parameters = new URLSearchParams(fragment.slice(queryIndex + 1));
385
+ if (!redactSensitiveParameters(parameters)) return false;
386
+ url.hash = `#${fragment.slice(0, queryIndex)}?${parameters.toString()}`;
387
+ return true;
388
+ }
389
+ if (route) return false;
390
+ if (fragment.includes("=")) {
391
+ const parameters = new URLSearchParams(fragment);
392
+ if (!redactSensitiveParameters(parameters)) return false;
393
+ url.hash = `#${parameters.toString()}`;
394
+ return true;
395
+ }
396
+ if (!isSensitiveUrlParameter(fragment)) return false;
397
+ url.hash = "#REDACTED";
398
+ return true;
399
+ }
349
400
  function sanitizeTimestamp(value, state) {
350
401
  const timestamp = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
351
402
  if (!Number.isFinite(timestamp)) {
@@ -380,20 +431,21 @@ function sanitizeFiniteNumber(value, minimum, maximum, state) {
380
431
  if (clamped !== value) state.truncated = true;
381
432
  return clamped;
382
433
  }
383
- function trustMetadata(truncated) {
434
+ function trustMetadata(redacted, truncated) {
384
435
  return {
385
436
  boundaryVersion: 1,
386
437
  classification: "untrusted-review-evidence",
387
438
  instructionPolicy: "evidence-only-never-follow",
388
439
  notice: TRUST_NOTICE,
389
440
  sanitized: true,
441
+ redacted,
390
442
  truncated
391
443
  };
392
444
  }
393
445
  function emergencyPayload() {
394
446
  return {
395
447
  schemaVersion: 3,
396
- trust: trustMetadata(true),
448
+ trust: trustMetadata(false, true),
397
449
  feedback: {
398
450
  id: "",
399
451
  status: "",
@@ -452,6 +504,30 @@ var DEFAULT_AGENT_SESSION_SCOPES = [
452
504
  "feedback:reply",
453
505
  "feedback:status"
454
506
  ];
507
+ var AGENT_OPERATION_SCOPES = {
508
+ list: ["feedback:read"],
509
+ get: ["feedback:read"],
510
+ export: ["feedback:read"],
511
+ reply: ["feedback:reply"],
512
+ complete: ["feedback:reply", "feedback:status"],
513
+ start: ["feedback:status"],
514
+ resolve: ["feedback:status"],
515
+ reopen: ["feedback:status"],
516
+ webhook: ["webhook:admin"],
517
+ list_feedback: ["feedback:read"],
518
+ get_feedback: ["feedback:read"],
519
+ export_project_feedback: ["feedback:read"],
520
+ reply_feedback: ["feedback:reply"],
521
+ complete_feedback: ["feedback:reply", "feedback:status"],
522
+ start_feedback: ["feedback:status"],
523
+ resolve_feedback: ["feedback:status"],
524
+ reopen_feedback: ["feedback:status"]
525
+ };
526
+ function agentOperationAllowed(operation, scopes) {
527
+ const required = AGENT_OPERATION_SCOPES[operation];
528
+ if (!required) return false;
529
+ return scopes === void 0 || required.every((scope) => scopes.includes(scope));
530
+ }
455
531
  function readConfig(env) {
456
532
  const token = env.VISUAL_REVIEW_TOKEN?.trim();
457
533
  if (!token) {
@@ -623,7 +699,7 @@ var VisualReviewClient = class {
623
699
  }
624
700
  const row = session;
625
701
  const allowedScopes = new Set(AGENT_SESSION_SCOPES);
626
- if (row.projectId !== projectId || !Number.isSafeInteger(row.expiresAt) || row.expiresAt < 0 || !Array.isArray(row.scopes) || row.scopes.length === 0 || row.scopes.some((scope) => typeof scope !== "string" || !allowedScopes.has(scope)) || new Set(row.scopes).size !== row.scopes.length) malformedSessionStatus();
702
+ if (row.projectId !== projectId || typeof row.displayName !== "string" || !row.displayName.trim() || row.displayName.length > 80 || !Number.isSafeInteger(row.expiresAt) || row.expiresAt < 0 || !Array.isArray(row.scopes) || row.scopes.length === 0 || row.scopes.some((scope) => typeof scope !== "string" || !allowedScopes.has(scope)) || new Set(row.scopes).size !== row.scopes.length) malformedSessionStatus();
627
703
  return row;
628
704
  }
629
705
  async #request(path, init) {
@@ -732,7 +808,7 @@ function assertSecureServiceUrl(value) {
732
808
 
733
809
  // ../review-agent/src/lifecycle.ts
734
810
  import { randomUUID } from "node:crypto";
735
- import { execFileSync } from "node:child_process";
811
+ import { execFileSync, spawn } from "node:child_process";
736
812
  import {
737
813
  appendFileSync,
738
814
  chmodSync,
@@ -775,6 +851,7 @@ async function statusCli(options) {
775
851
  projectId: options.env.VISUAL_REVIEW_PROJECT_ID?.trim() || null,
776
852
  expiresAt: null,
777
853
  scopes: null,
854
+ displayName: null,
778
855
  configSource
779
856
  };
780
857
  }
@@ -786,6 +863,7 @@ async function statusCli(options) {
786
863
  let connectionReason = "expired";
787
864
  let remoteExpiresAt;
788
865
  let remoteScopes;
866
+ let remoteDisplayName;
789
867
  const now = options.now?.() ?? Date.now();
790
868
  if (state.expiresAt === void 0 || state.expiresAt > now) {
791
869
  try {
@@ -798,6 +876,7 @@ async function statusCli(options) {
798
876
  connectionReason = connected ? "connected" : "expired";
799
877
  remoteExpiresAt = session.expiresAt;
800
878
  remoteScopes = session.scopes;
879
+ remoteDisplayName = session.displayName;
801
880
  } catch (cause) {
802
881
  connectionReason = statusConnectionFailureReason(cause);
803
882
  }
@@ -810,6 +889,7 @@ async function statusCli(options) {
810
889
  projectId: state.config.projectId,
811
890
  expiresAt: remoteExpiresAt ?? state.expiresAt ?? null,
812
891
  scopes: remoteScopes ?? state.scopes ?? null,
892
+ displayName: remoteDisplayName ?? state.config.displayName ?? null,
813
893
  configSource
814
894
  };
815
895
  }
@@ -828,15 +908,58 @@ async function configureCli(command, options) {
828
908
  );
829
909
  }
830
910
  const serviceUrl = secureServiceUrl(command.serviceUrl ?? DEFAULT_SERVICE_URL);
831
- const prompt = options.prompt ?? defaultPrompt;
832
- const email = ownerEmail(
833
- command.email ?? options.env.VISUAL_REVIEW_OWNER_EMAIL ?? await prompt("Owner \uC774\uBA54\uC77C: ", false)
834
- );
835
911
  const request = requestJson(options.fetch ?? globalThis.fetch);
836
912
  const runtime = await request(`${serviceUrl}/v1/agency/runtime`, { method: "GET" });
837
913
  if (runtime.provider !== "convex") {
838
914
  throw new Error("\uC120\uD0DD\uD55C \uC11C\uBE44\uC2A4\uB294 \uACF5\uAC1C Visual Review CLI\uB97C \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
839
915
  }
916
+ const repository = resolve(options.cwd, command.repository ?? ".");
917
+ if (!existsSync(repository) || !statSync(repository).isDirectory()) {
918
+ throw new Error(`\uCF54\uB4DC \uC800\uC7A5\uC18C \uACBD\uB85C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4: ${repository}`);
919
+ }
920
+ const displayName = normalizeAgentDisplayName(command.displayName ?? "Agent");
921
+ const requestedScopes = requestedAgentScopes(command.webhookAdmin, command.readOnly === true);
922
+ const terminalAuthentication = command.noBrowser || command.list || command.email !== void 0 || Boolean(options.env.VISUAL_REVIEW_OWNER_EMAIL?.trim()) || Boolean(options.env.VISUAL_REVIEW_AUTH_CODE?.trim());
923
+ if (command.list) {
924
+ return { projects: (await authenticateWithEmail(command, options, serviceUrl, request)).projects };
925
+ }
926
+ const authenticated = terminalAuthentication ? await createSessionWithEmail(command, options, serviceUrl, request, requestedScopes, displayName) : await createSessionWithBrowser(command, options, serviceUrl, request, requestedScopes, displayName);
927
+ const { project, session } = authenticated;
928
+ if (typeof session.token !== "string" || session.projectId !== project.id || !Number.isSafeInteger(session.expiresAt) || session.expiresAt <= (options.now?.() ?? Date.now())) {
929
+ throw new Error("\uD504\uB85C\uC81D\uD2B8 CLI session \uC751\uB2F5\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
930
+ }
931
+ const scopes = session.scopes === void 0 ? requestedScopes : agentSessionScopes(session.scopes, "\uD504\uB85C\uC81D\uD2B8 CLI session");
932
+ if (scopes.length !== requestedScopes.length || scopes.some((scope) => !requestedScopes.includes(scope))) {
933
+ throw new Error("\uD504\uB85C\uC81D\uD2B8 CLI session scope\uAC00 \uC694\uCCAD\uACFC \uC77C\uCE58\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
934
+ }
935
+ if (normalizeAgentDisplayName(session.displayName) !== displayName) {
936
+ throw new Error("\uD504\uB85C\uC81D\uD2B8 CLI session displayName\uC774 \uC694\uCCAD\uACFC \uC77C\uCE58\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
937
+ }
938
+ const configPath = resolve(options.cwd, command.output ?? resolve(repository, ".visual-review.json"));
939
+ ensureCredentialIgnored(repository, configPath);
940
+ writePrivateFileAtomically(configPath, `${JSON.stringify({
941
+ serviceUrl,
942
+ token: session.token,
943
+ projectId: project.id,
944
+ displayName,
945
+ expiresAt: session.expiresAt,
946
+ scopes
947
+ }, null, 2)}
948
+ `);
949
+ return {
950
+ configured: true,
951
+ project,
952
+ expiresAt: session.expiresAt,
953
+ scopes,
954
+ displayName,
955
+ configPath
956
+ };
957
+ }
958
+ async function authenticateWithEmail(command, options, serviceUrl, request) {
959
+ const prompt = options.prompt ?? defaultPrompt;
960
+ const email = ownerEmail(
961
+ command.email ?? options.env.VISUAL_REVIEW_OWNER_EMAIL ?? await prompt("Owner \uC774\uBA54\uC77C: ", false)
962
+ );
840
963
  const challenge = await request(`${serviceUrl}/v1/agency/auth/code`, {
841
964
  method: "POST",
842
965
  body: JSON.stringify({ email, intent: "sign-in" })
@@ -854,49 +977,88 @@ async function configureCli(command, options) {
854
977
  method: "GET",
855
978
  headers: { authorization: `Bearer ${verified.token}` }
856
979
  });
857
- const projects = projectList(catalog.projects);
858
- if (command.list) return { projects };
980
+ return { projects: projectList(catalog.projects), ownerToken: verified.token };
981
+ }
982
+ async function createSessionWithEmail(command, options, serviceUrl, request, scopes, displayName) {
983
+ const { projects, ownerToken } = await authenticateWithEmail(
984
+ command,
985
+ options,
986
+ serviceUrl,
987
+ request
988
+ );
859
989
  const project = command.projectId ? projects.find(({ id }) => id === command.projectId) : projects.length === 1 ? projects[0] : void 0;
860
990
  if (!project) {
861
991
  throw new Error(projects.length === 1 ? `\uD504\uB85C\uC81D\uD2B8 ${command.projectId}\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.` : "\uD504\uB85C\uC81D\uD2B8\uAC00 \uC5EC\uB7EC \uAC1C\uC785\uB2C8\uB2E4. configure --list\uB85C \uD655\uC778\uD558\uACE0 --project <id>\uB97C \uC9C0\uC815\uD558\uC138\uC694.");
862
992
  }
863
- const requestedScopes = requestedAgentScopes(command.webhookAdmin);
864
993
  const session = await request(`${serviceUrl}/v1/agency/agent-sessions`, {
865
994
  method: "POST",
866
- headers: { authorization: `Bearer ${verified.token}` },
995
+ headers: { authorization: `Bearer ${ownerToken}` },
996
+ body: JSON.stringify({ projectId: project.id, displayName, scopes })
997
+ });
998
+ return { project, session };
999
+ }
1000
+ async function createSessionWithBrowser(command, options, serviceUrl, request, scopes, displayName) {
1001
+ const started = await request(`${serviceUrl}/v1/agency/cli-authorizations`, {
1002
+ method: "POST",
867
1003
  body: JSON.stringify({
868
- projectId: project.id,
869
- scopes: requestedScopes
1004
+ scopes,
1005
+ displayName,
1006
+ ...command.projectId === void 0 ? {} : { projectId: command.projectId }
870
1007
  })
871
1008
  });
872
- if (typeof session.token !== "string" || session.projectId !== project.id || !Number.isSafeInteger(session.expiresAt) || session.expiresAt <= (options.now?.() ?? Date.now())) {
873
- throw new Error("\uD504\uB85C\uC81D\uD2B8 CLI session \uC751\uB2F5\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
874
- }
875
- const scopes = session.scopes === void 0 ? requestedScopes : agentSessionScopes(session.scopes, "\uD504\uB85C\uC81D\uD2B8 CLI session");
876
- if (scopes.length !== requestedScopes.length || scopes.some((scope) => !requestedScopes.includes(scope))) {
877
- throw new Error("\uD504\uB85C\uC81D\uD2B8 CLI session scope\uAC00 \uC694\uCCAD\uACFC \uC77C\uCE58\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
878
- }
879
- const repository = resolve(options.cwd, command.repository ?? ".");
880
- if (!existsSync(repository) || !statSync(repository).isDirectory()) {
881
- throw new Error(`\uCF54\uB4DC \uC800\uC7A5\uC18C \uACBD\uB85C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4: ${repository}`);
882
- }
883
- const configPath = resolve(options.cwd, command.output ?? resolve(repository, ".visual-review.json"));
884
- ensureCredentialIgnored(repository, configPath);
885
- writePrivateFileAtomically(configPath, `${JSON.stringify({
1009
+ const requestId = authorizationId(started.requestId);
1010
+ const requestToken = authorizationToken(started.requestToken);
1011
+ const verificationCode2 = browserVerificationCode(started.verificationCode);
1012
+ const expiresAt = authorizationExpiry(started.expiresAt, options.now?.() ?? Date.now());
1013
+ const authorizationUrl = browserAuthorizationUrl(
1014
+ started.verificationUri,
886
1015
  serviceUrl,
887
- token: session.token,
888
- projectId: project.id,
889
- expiresAt: session.expiresAt,
890
- scopes
891
- }, null, 2)}
892
- `);
893
- return {
894
- configured: true,
895
- project,
896
- expiresAt: session.expiresAt,
897
- scopes,
898
- configPath
899
- };
1016
+ requestId
1017
+ );
1018
+ options.warning?.(`\uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C Visual Review \uB85C\uADF8\uC778\uC744 \uC2B9\uC778\uD558\uC138\uC694: ${authorizationUrl}`);
1019
+ options.warning?.(`\uD655\uC778 \uCF54\uB4DC: ${verificationCode2}`);
1020
+ let opened = false;
1021
+ try {
1022
+ opened = await (options.openBrowser ?? defaultOpenBrowser)(authorizationUrl);
1023
+ } catch {
1024
+ opened = false;
1025
+ }
1026
+ if (!opened) options.warning?.("\uBE0C\uB77C\uC6B0\uC800\uB97C \uC790\uB3D9\uC73C\uB85C \uC5F4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. \uC704 URL\uC744 \uC9C1\uC811 \uC5EC\uC138\uC694.");
1027
+ const wait = options.wait ?? ((milliseconds) => new Promise((resolveWait) => {
1028
+ setTimeout(resolveWait, milliseconds);
1029
+ }));
1030
+ const maximumAttempts = Math.max(1, Math.ceil((expiresAt - (options.now?.() ?? Date.now())) / 2e3));
1031
+ for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
1032
+ const exchanged = await request(
1033
+ `${serviceUrl}/v1/agency/cli-authorizations/${encodeURIComponent(requestId)}/token`,
1034
+ {
1035
+ method: "POST",
1036
+ body: JSON.stringify({ requestToken })
1037
+ }
1038
+ );
1039
+ if (exchanged.status !== "pending") {
1040
+ const token = authorizationToken(exchanged.token);
1041
+ const projectId = authorizationId(exchanged.projectId);
1042
+ const projectName = authorizationProjectName(exchanged.projectName);
1043
+ const sessionScopes = agentSessionScopes(exchanged.scopes, "\uD504\uB85C\uC81D\uD2B8 CLI session");
1044
+ const sessionExpiresAt = authorizationExpiry(
1045
+ exchanged.expiresAt,
1046
+ options.now?.() ?? Date.now()
1047
+ );
1048
+ return {
1049
+ project: { id: projectId, name: projectName },
1050
+ session: {
1051
+ token,
1052
+ projectId,
1053
+ displayName: normalizeAgentDisplayName(exchanged.displayName),
1054
+ scopes: sessionScopes,
1055
+ expiresAt: sessionExpiresAt
1056
+ }
1057
+ };
1058
+ }
1059
+ await wait(2e3);
1060
+ }
1061
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uB85C\uADF8\uC778 \uC694\uCCAD\uC774 \uB9CC\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. configure\uB97C \uB2E4\uC2DC \uC2E4\uD589\uD558\uC138\uC694.");
900
1062
  }
901
1063
  async function logoutCli(command, options) {
902
1064
  const state = readCliConfigState(options.env, options.cwd, {
@@ -956,10 +1118,15 @@ function readCliConfigState(env, cwd, options) {
956
1118
  VISUAL_REVIEW_PROJECT_ID: env.VISUAL_REVIEW_PROJECT_ID ?? stringValue(fileConfig.projectId),
957
1119
  VISUAL_REVIEW_SERVICE_URL: env.VISUAL_REVIEW_SERVICE_URL ?? stringValue(fileConfig.serviceUrl)
958
1120
  });
1121
+ const displayName = fileConfig.displayName === void 0 ? void 0 : normalizeAgentDisplayName(fileConfig.displayName);
959
1122
  const expiresAt = usesFileToken && Number.isSafeInteger(fileConfig.expiresAt) ? fileConfig.expiresAt : void 0;
960
1123
  const scopes = usesFileToken && fileConfig.scopes !== void 0 ? agentSessionScopes(fileConfig.scopes, configPath) : void 0;
961
1124
  return {
962
- config,
1125
+ config: {
1126
+ ...config,
1127
+ ...displayName === void 0 ? {} : { displayName },
1128
+ ...scopes === void 0 ? {} : { scopes }
1129
+ },
963
1130
  ...expiresAt === void 0 ? {} : { expiresAt },
964
1131
  ...scopes === void 0 ? {} : { scopes },
965
1132
  configPath,
@@ -967,12 +1134,24 @@ function readCliConfigState(env, cwd, options) {
967
1134
  usesFileToken
968
1135
  };
969
1136
  }
970
- function requestedAgentScopes(webhookAdmin) {
1137
+ function requestedAgentScopes(webhookAdmin, readOnly) {
1138
+ if (readOnly) return ["feedback:read"];
971
1139
  return [
972
1140
  ...DEFAULT_AGENT_SESSION_SCOPES,
973
1141
  ...webhookAdmin ? ["webhook:admin"] : []
974
1142
  ];
975
1143
  }
1144
+ function normalizeAgentDisplayName(value) {
1145
+ if (typeof value !== "string") throw new Error("agent displayName \uAC12\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1146
+ if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) {
1147
+ throw new Error("agent displayName \uAC12\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1148
+ }
1149
+ const normalized = value.normalize("NFC").trim().replace(/\s+/gu, " ");
1150
+ if (!normalized || normalized.length > 80) {
1151
+ throw new Error("agent displayName\uC740 1\uC790 \uC774\uC0C1 80\uC790 \uC774\uD558\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
1152
+ }
1153
+ return normalized;
1154
+ }
976
1155
  function agentSessionScopes(value, context) {
977
1156
  if (!Array.isArray(value) || value.length === 0) {
978
1157
  throw new Error(`${context} scopes \uAC12\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.`);
@@ -985,7 +1164,15 @@ function agentSessionScopes(value, context) {
985
1164
  }
986
1165
  scopes.push(scope);
987
1166
  }
988
- return scopes;
1167
+ const requested = new Set(scopes);
1168
+ const readOnly = requested.size === 1 && requested.has("feedback:read");
1169
+ const defaultAccess = DEFAULT_AGENT_SESSION_SCOPES.every((scope) => requested.has(scope));
1170
+ const defaultProfile = defaultAccess && requested.size === DEFAULT_AGENT_SESSION_SCOPES.length;
1171
+ const webhookProfile = defaultAccess && requested.has("webhook:admin") && requested.size === DEFAULT_AGENT_SESSION_SCOPES.length + 1;
1172
+ if (!readOnly && !defaultProfile && !webhookProfile) {
1173
+ throw new Error(`${context} scopes\uB294 read-only, default \uB610\uB294 default+webhook \uD504\uB85C\uD544\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.`);
1174
+ }
1175
+ return readOnly ? ["feedback:read"] : [...DEFAULT_AGENT_SESSION_SCOPES, ...webhookProfile ? ["webhook:admin"] : []];
989
1176
  }
990
1177
  function requestJson(fetcher) {
991
1178
  return async (url, init) => {
@@ -1242,6 +1429,76 @@ function verificationCode(value) {
1242
1429
  if (!/^\d{6}$/u.test(normalized)) throw new Error("6\uC790\uB9AC \uC774\uBA54\uC77C \uC778\uC99D \uCF54\uB4DC\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.");
1243
1430
  return normalized;
1244
1431
  }
1432
+ function authorizationId(value) {
1433
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
1434
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(normalized)) {
1435
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D \uC751\uB2F5\uC758 ID\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1436
+ }
1437
+ return normalized;
1438
+ }
1439
+ function authorizationToken(value) {
1440
+ const normalized = typeof value === "string" ? value.trim() : "";
1441
+ if (!/^[A-Za-z0-9_-]{43}$/u.test(normalized)) {
1442
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D \uC751\uB2F5\uC758 token\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1443
+ }
1444
+ return normalized;
1445
+ }
1446
+ function browserVerificationCode(value) {
1447
+ const normalized = typeof value === "string" ? value.trim().toUpperCase() : "";
1448
+ if (!/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/u.test(normalized)) {
1449
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D \uD655\uC778 \uCF54\uB4DC\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1450
+ }
1451
+ return normalized;
1452
+ }
1453
+ function authorizationExpiry(value, now) {
1454
+ if (!Number.isSafeInteger(value) || value <= now) {
1455
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D \uC694\uCCAD\uC758 \uB9CC\uB8CC \uC2DC\uAC04\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1456
+ }
1457
+ return value;
1458
+ }
1459
+ function authorizationProjectName(value) {
1460
+ const normalized = typeof value === "string" ? value.trim() : "";
1461
+ if (!normalized || normalized.length > 200) {
1462
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D \uD504\uB85C\uC81D\uD2B8 \uC774\uB984\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1463
+ }
1464
+ return normalized;
1465
+ }
1466
+ function browserAuthorizationUrl(value, serviceUrl, requestId) {
1467
+ const fallback = `${serviceUrl}/cli/authorize?request=${encodeURIComponent(requestId)}`;
1468
+ if (value === void 0) return fallback;
1469
+ if (typeof value !== "string" || value.length > 2048) {
1470
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D URL\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1471
+ }
1472
+ try {
1473
+ const url = new URL(value);
1474
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
1475
+ if (url.username || url.password || url.hash || url.pathname !== "/cli/authorize" || url.searchParams.size !== 1 || url.searchParams.get("request") !== requestId || url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new Error();
1476
+ return url.href;
1477
+ } catch {
1478
+ throw new Error("\uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D URL\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
1479
+ }
1480
+ }
1481
+ async function defaultOpenBrowser(url) {
1482
+ const command = process.platform === "darwin" ? { file: "open", args: [url] } : process.platform === "win32" ? { file: "cmd", args: ["/c", "start", "", url] } : { file: "xdg-open", args: [url] };
1483
+ return new Promise((resolveOpen) => {
1484
+ let settled = false;
1485
+ const child = spawn(command.file, command.args, {
1486
+ detached: true,
1487
+ stdio: "ignore",
1488
+ windowsHide: true
1489
+ });
1490
+ const finish = (opened) => {
1491
+ if (settled) return;
1492
+ settled = true;
1493
+ resolveOpen(opened);
1494
+ };
1495
+ child.once("error", () => finish(false));
1496
+ child.once("spawn", () => {
1497
+ child.unref();
1498
+ finish(true);
1499
+ });
1500
+ });
1501
+ }
1245
1502
  function secureServiceUrl(value) {
1246
1503
  const normalized = value.trim().replace(/\/+$/u, "");
1247
1504
  try {
@@ -1341,11 +1598,11 @@ function summarize(payload) {
1341
1598
  var CLI_HELP = `Visual Review CLI
1342
1599
 
1343
1600
  Usage:
1344
- visual-review configure [--email <email>] [--project <id>] [--list]
1601
+ visual-review configure [--email <email>] [--project <id>] [--list] [--no-browser]
1345
1602
  [--service-url <url>] [--repo <path> | --output <path>]
1346
- [--webhook-admin]
1603
+ [--name <display-name>] [--read-only | --webhook-admin]
1347
1604
  visual-review status
1348
- visual-review list [--status open|resolved|all] [--limit 1..100] [--cursor <cursor>]
1605
+ visual-review list [--status open|in_progress|resolved|all] [--limit 1..100] [--cursor <cursor>]
1349
1606
  visual-review get <comment-id>
1350
1607
  visual-review export [--format json|markdown] [--output <new-file>]
1351
1608
  visual-review reply <comment-id> (--body <text> | --body-file <path>)
@@ -1358,6 +1615,8 @@ Usage:
1358
1615
  --expected-thread-revision <revision>]
1359
1616
  visual-review reopen <comment-id> [--expected-workflow-revision <revision>
1360
1617
  --expected-thread-revision <revision>]
1618
+ visual-review start <comment-id> [--expected-workflow-revision <revision>
1619
+ --expected-thread-revision <revision>]
1361
1620
  visual-review webhook get|delete
1362
1621
  visual-review webhook set --url <https-url>
1363
1622
  (--secret <32..256 chars> | --secret-file <path>) [--inactive]
@@ -1376,16 +1635,18 @@ Configuration:
1376
1635
  Run visual-review help <command> or visual-review <command> --help for details.
1377
1636
  `;
1378
1637
  var CLI_HELP_TOPICS = {
1379
- configure: `Usage: visual-review configure [--email <email>] [--project <id>] [--list]
1380
- [--service-url <https-url>] [--repo <path> | --output <path>] [--webhook-admin]
1638
+ configure: `Usage: visual-review configure [--email <email>] [--project <id>] [--list] [--no-browser]
1639
+ [--service-url <https-url>] [--repo <path> | --output <path>] [--name <display-name>]
1640
+ [--read-only | --webhook-admin]
1381
1641
 
1382
- Authenticates with a hidden email code and writes one project-scoped credential.
1642
+ Opens browser approval by default and writes one named project-scoped credential.
1643
+ --read-only requests only feedback:read. --no-browser (or --email) uses a hidden terminal email code.
1383
1644
  `,
1384
1645
  status: `Usage: visual-review status
1385
1646
 
1386
1647
  Returns configuration, connectionReason, expiry, and scopes as JSON.
1387
1648
  `,
1388
- list: `Usage: visual-review list [--status open|resolved|all] [--limit 1..100] [--cursor <cursor>]
1649
+ list: `Usage: visual-review list [--status open|in_progress|resolved|all] [--limit 1..100] [--cursor <cursor>]
1389
1650
 
1390
1651
  Lists compact feedback summaries. Pass nextCursor to --cursor when hasMore is true.
1391
1652
  `,
@@ -1413,6 +1674,10 @@ Without revisions, the CLI reads the latest pair first and still uses CAS.
1413
1674
  reopen: `Usage: visual-review reopen <comment-id> [--expected-workflow-revision <revision> --expected-thread-revision <revision>]
1414
1675
 
1415
1676
  Without revisions, the CLI reads the latest pair first and still uses CAS.
1677
+ `,
1678
+ start: `Usage: visual-review start <comment-id> [--expected-workflow-revision <revision> --expected-thread-revision <revision>]
1679
+
1680
+ Moves requested feedback to in progress. Without revisions, the CLI reads the latest pair first.
1416
1681
  `,
1417
1682
  webhook: `Usage: visual-review webhook get|delete
1418
1683
  visual-review webhook set --url <https-url> (--secret <text> | --secret-file <path>) [--inactive]
@@ -1425,7 +1690,7 @@ Revokes the remote session before removing the local credential unless --local-o
1425
1690
  `
1426
1691
  };
1427
1692
  var CLI_COMMAND_NAMES = Object.keys(CLI_HELP_TOPICS);
1428
- var CLI_VERSION = "0.14.0";
1693
+ var CLI_VERSION = "0.15.0";
1429
1694
  var CliUsageError = class extends Error {
1430
1695
  };
1431
1696
  function parseCliCommand(args) {
@@ -1452,8 +1717,11 @@ function parseCliCommand(args) {
1452
1717
  let serviceUrl;
1453
1718
  let output;
1454
1719
  let repository;
1720
+ let displayName;
1455
1721
  let list = false;
1456
1722
  let webhookAdmin = false;
1723
+ let readOnly = false;
1724
+ let noBrowser = false;
1457
1725
  for (let index = 0; index < rest.length; index += 1) {
1458
1726
  const token = rest[index];
1459
1727
  if (token === "--list") {
@@ -1464,9 +1732,17 @@ function parseCliCommand(args) {
1464
1732
  webhookAdmin = true;
1465
1733
  continue;
1466
1734
  }
1735
+ if (token === "--read-only") {
1736
+ readOnly = true;
1737
+ continue;
1738
+ }
1739
+ if (token === "--no-browser") {
1740
+ noBrowser = true;
1741
+ continue;
1742
+ }
1467
1743
  const [flagValue, inlineValue] = token.split("=", 2);
1468
1744
  const flag = flagValue;
1469
- if (!["--email", "--project", "--service-url", "--output", "--repo"].includes(flag)) {
1745
+ if (!["--email", "--project", "--service-url", "--output", "--repo", "--name"].includes(flag)) {
1470
1746
  throw new CliUsageError(`\uC54C \uC218 \uC5C6\uB294 configure \uC635\uC158: ${token}`);
1471
1747
  }
1472
1748
  const value = inlineValue ?? rest[++index];
@@ -1476,17 +1752,22 @@ function parseCliCommand(args) {
1476
1752
  if (flag === "--service-url") serviceUrl = value;
1477
1753
  if (flag === "--output") output = value;
1478
1754
  if (flag === "--repo") repository = value;
1755
+ if (flag === "--name") displayName = value;
1479
1756
  }
1480
1757
  if (output && repository) throw new CliUsageError("--output\uACFC --repo\uB294 \uD568\uAED8 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
1758
+ if (readOnly && webhookAdmin) throw new CliUsageError("--read-only\uC640 --webhook-admin\uC740 \uD568\uAED8 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
1481
1759
  return {
1482
1760
  name,
1483
1761
  list,
1484
1762
  webhookAdmin,
1763
+ readOnly,
1764
+ noBrowser,
1485
1765
  ...email ? { email } : {},
1486
1766
  ...projectId ? { projectId } : {},
1487
1767
  ...serviceUrl ? { serviceUrl } : {},
1488
1768
  ...output ? { output } : {},
1489
- ...repository ? { repository } : {}
1769
+ ...repository ? { repository } : {},
1770
+ ...displayName ? { displayName } : {}
1490
1771
  };
1491
1772
  }
1492
1773
  if (name === "status") {
@@ -1507,8 +1788,8 @@ function parseCliCommand(args) {
1507
1788
  const [flag, inlineValue] = token.split("=", 2);
1508
1789
  if (flag === "--status") {
1509
1790
  const value = inlineValue ?? rest[++index];
1510
- if (value !== "open" && value !== "resolved" && value !== "all") {
1511
- throw new CliUsageError("--status\uB294 'open', 'resolved', 'all' \uC911 \uD558\uB098\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
1791
+ if (value !== "open" && value !== "in_progress" && value !== "resolved" && value !== "all") {
1792
+ throw new CliUsageError("--status\uB294 'open', 'in_progress', 'resolved', 'all' \uC911 \uD558\uB098\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
1512
1793
  }
1513
1794
  status = value;
1514
1795
  continue;
@@ -1698,7 +1979,7 @@ function parseCliCommand(args) {
1698
1979
  ...secretFile === void 0 ? {} : { secretFile }
1699
1980
  };
1700
1981
  }
1701
- if (name === "resolve" || name === "reopen") {
1982
+ if (name === "start" || name === "resolve" || name === "reopen") {
1702
1983
  const commentId = rest[0]?.trim();
1703
1984
  if (!commentId) throw new CliUsageError(`${name} \uBA85\uB839\uC5D0\uB294 comment-id\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.`);
1704
1985
  let expectedWorkflowRevision;
@@ -1822,7 +2103,7 @@ ${formatAgentFeedbackMarkdown(feedback)}`).join("\n\n---\n\n")
1822
2103
  return client.setStatus(
1823
2104
  projectId,
1824
2105
  command.commentId,
1825
- command.name === "resolve" ? "resolved" : "open",
2106
+ command.name === "resolve" ? "resolved" : command.name === "start" ? "in_progress" : "open",
1826
2107
  precondition
1827
2108
  );
1828
2109
  }
@@ -1878,6 +2159,8 @@ async function runCli(args, options = {}) {
1878
2159
  fetch: options.fetch,
1879
2160
  now: options.now,
1880
2161
  prompt: options.prompt,
2162
+ openBrowser: options.openBrowser,
2163
+ wait: options.wait,
1881
2164
  warning: (text) => stderr(`${text}
1882
2165
  `)
1883
2166
  };
@@ -1900,6 +2183,7 @@ async function runCli(args, options = {}) {
1900
2183
  now: lifecycleOptions.now,
1901
2184
  warning: lifecycleOptions.warning
1902
2185
  });
2186
+ assertCommandScope(command, config.scopes);
1903
2187
  sensitiveValues.add(config.token);
1904
2188
  const result = await executeCliCommand(
1905
2189
  new VisualReviewClient({
@@ -1932,6 +2216,11 @@ async function runCli(args, options = {}) {
1932
2216
  return cause instanceof CliUsageError ? 2 : 1;
1933
2217
  }
1934
2218
  }
2219
+ function assertCommandScope(command, scopes) {
2220
+ if (!agentOperationAllowed(command.name, scopes)) {
2221
+ throw new CliUsageError("\uC774 CLI session\uC758 \uC2B9\uC778\uB41C scope\uB85C\uB294 \uC774 \uBA85\uB839\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
2222
+ }
2223
+ }
1935
2224
  function cliErrorEnvelope(cause, sensitiveValues) {
1936
2225
  if (cause instanceof CliUsageError) {
1937
2226
  return {