@doist/doistbot-cli 1.0.14 → 1.0.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/doistbot-cli",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Local self-review CLI for Doistbot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -51,7 +51,7 @@
51
51
  "commander": "14.0.3",
52
52
  "js-yaml": "4.3.2",
53
53
  "octokit": "5.0.5",
54
- "undici": "8.10.2",
54
+ "undici": "8.11.2",
55
55
  "yocto-spinner": "1.2.2"
56
56
  },
57
57
  "devDependencies": {
@@ -7,6 +7,8 @@ export type LoggerOptions = {
7
7
  suppressLogs?: (level: LogLevel) => boolean;
8
8
  suppressInfoLogs?: () => boolean;
9
9
  includeLogField?: boolean;
10
+ /** Receives serialized JSON without a trailing newline. The writer must add one. */
11
+ write?: (line: string) => void;
10
12
  buildInfo?: {
11
13
  version: number;
12
14
  commit: string;
@@ -33,8 +33,14 @@ export function createLogger(options = {}) {
33
33
  }
34
34
  Object.assign(payload, traceInfo);
35
35
  payload.meta = metaInfo;
36
- // eslint-disable-next-line no-console
37
- console.log(JSON.stringify(payload));
36
+ const line = JSON.stringify(payload);
37
+ if (options.write) {
38
+ options.write(line);
39
+ }
40
+ else {
41
+ // eslint-disable-next-line no-console
42
+ console.log(line);
43
+ }
38
44
  }
39
45
  return {
40
46
  info: (message, meta) => log('info', message, meta),
@@ -0,0 +1,59 @@
1
+ import { writeSync } from 'node:fs';
2
+ import { scrubPii } from './pii-scrubber.js';
3
+ // Pi applies tool_result changes before sending the result to the model. Filter
4
+ // all text tools so redirecting CLI output to a file cannot bypass filtering.
5
+ // eslint-disable-next-line import/no-default-export
6
+ export default function toolOutputFilterExtension(pi) {
7
+ const secrets = Object.entries(process.env)
8
+ .filter(([key, value]) => /(?:KEY|TOKEN|PASSWORD|SECRET)$/.test(key) && value && value.length >= 8)
9
+ .map(([, value]) => value);
10
+ function filterText(value) {
11
+ for (const secret of secrets)
12
+ value = value.split(secret).join('[REDACTED_SECRET]');
13
+ return scrubPii(value);
14
+ }
15
+ function filterValue(value) {
16
+ if (typeof value === 'string')
17
+ return filterText(value);
18
+ if (Array.isArray(value))
19
+ return value.map(filterValue);
20
+ if (value && typeof value === 'object')
21
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [filterText(key), filterValue(entry)]));
22
+ return value;
23
+ }
24
+ pi.on('tool_call', (event) => {
25
+ try {
26
+ // Pi redirects process.stdout writes to stderr in JSON mode.
27
+ // Write this protocol record directly to its stdout pipe.
28
+ writeSync(1, `${JSON.stringify({
29
+ type: 'doistbot_tool_audit',
30
+ tool: event.toolName,
31
+ // Audit queries/commands, never service response bodies.
32
+ input: JSON.stringify(filterValue(event.input)).slice(0, 8_000),
33
+ })}\n`);
34
+ }
35
+ catch {
36
+ return { block: true, reason: 'Tool query could not be safely recorded.' };
37
+ }
38
+ return undefined;
39
+ });
40
+ pi.on('tool_result', (event) => {
41
+ try {
42
+ return {
43
+ content: event.content.map((item) => item.type === 'text' ? { ...item, text: filterText(item.text) } : item),
44
+ details: filterValue(event.details ?? null),
45
+ };
46
+ }
47
+ catch {
48
+ // Pi otherwise ignores extension errors and keeps the original
49
+ // result. Explicitly replace it so a filter failure cannot leak text.
50
+ return {
51
+ content: [
52
+ { type: 'text', text: 'Tool output withheld: filtering failed.' },
53
+ ],
54
+ details: null,
55
+ isError: true,
56
+ };
57
+ }
58
+ });
59
+ }
@@ -3,7 +3,9 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
3
3
  import { tmpdir } from 'os';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
+ import { logger } from './logging.js';
6
7
  import { PI_EXECUTION_LIMIT_ENV } from './pi-execution-limits-extension.js';
8
+ import { approvedSkillArgs, buildToolEnvironment } from './tool-environment.js';
7
9
  export function toPiInvocationTelemetryFields(telemetry) {
8
10
  return {
9
11
  turn_count: telemetry.turnCount,
@@ -54,7 +56,7 @@ export const DEFAULT_PI_MODELS = {
54
56
  gemini: 'gemini-3.1-pro-preview',
55
57
  grok: 'grok-4.6',
56
58
  zai: 'glm5.2',
57
- openai: 'gpt-6-sol',
59
+ openai: 'gpt-6.1-sol',
58
60
  };
59
61
  export const DEFAULT_OPENROUTER_MODELS = {
60
62
  deepseek: 'deepseek/deepseek-v4-pro-0813',
@@ -95,6 +97,7 @@ function resolvePiCliPath() {
95
97
  }
96
98
  const PI_CLI_PATH = resolvePiCliPath();
97
99
  const PI_EXECUTION_LIMIT_EXTENSION_PATH = fileURLToPath(new URL('./pi-execution-limits-extension.js', import.meta.url));
100
+ const PI_TOOL_OUTPUT_FILTER_EXTENSION_PATH = fileURLToPath(new URL('./pi-tool-output-filter-extension.js', import.meta.url));
98
101
  let cachedNodeExecutable;
99
102
  let cachedOpenRouterSessionExtensionPath;
100
103
  export function resolveNodeExecutable({ execPath = process.execPath, argv0 = process.argv0, canExecute = canExecuteNode, } = {}) {
@@ -416,7 +419,11 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
416
419
  'json',
417
420
  '--no-session',
418
421
  '--no-extensions',
422
+ ...(process.env.SANDBOX_PROFILE
423
+ ? ['--extension', PI_TOOL_OUTPUT_FILTER_EXTENSION_PATH]
424
+ : []),
419
425
  '--no-skills',
426
+ ...approvedSkillArgs(),
420
427
  '--no-prompt-templates',
421
428
  ...(noContextFiles ? ['--no-context-files'] : []),
422
429
  '--model',
@@ -428,21 +435,11 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
428
435
  ...executionLimitExtension.args,
429
436
  ...imageArgs,
430
437
  ];
431
- const inheritedEnv = {
432
- ...process.env,
433
- };
434
- // Repository-scoped credentials must be passed explicitly through
435
- // extraEnv by an authorized task invocation.
436
- delete inheritedEnv.GH_PACKAGES_TOKEN;
437
- const childEnv = {
438
- ...inheritedEnv,
439
- HOME: process.env.HOME,
440
- PATH: process.env.PATH,
441
- GIT_TERMINAL_PROMPT: '0',
438
+ const childEnv = buildToolEnvironment({
442
439
  ...extraEnv,
443
440
  ...openRouterSessionExtension.extraEnv,
444
441
  ...executionLimitExtension.extraEnv,
445
- };
442
+ });
446
443
  const apiKeyEnvVar = PI_API_KEY_ENV_VAR_BY_PI_PROVIDER[resolvedPiProviderId];
447
444
  if (piAuthProviderId) {
448
445
  delete childEnv[apiKeyEnvVar];
@@ -450,7 +447,6 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
450
447
  else {
451
448
  childEnv[apiKeyEnvVar] = apiKey;
452
449
  }
453
- delete childEnv.GITHUB_TOKEN;
454
450
  try {
455
451
  const child = spawn(resolveNodeExecutable(), args, {
456
452
  cwd,
@@ -576,6 +572,13 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
576
572
  }
577
573
  try {
578
574
  const parsed = JSON.parse(trimmed);
575
+ if (parsed.type === 'doistbot_tool_audit') {
576
+ logger.info('Sandbox tool query', {
577
+ tool: parsed.tool,
578
+ input: parsed.input,
579
+ });
580
+ continue;
581
+ }
579
582
  if (parsed.type === 'session') {
580
583
  sessionHeaderSeen = true;
581
584
  continue;
@@ -0,0 +1,22 @@
1
+ /** Prepare credentials for Pi shell tools. */
2
+ export function buildToolEnvironment(extraEnv = {}, inherited = process.env) {
3
+ const env = { ...inherited };
4
+ delete env.GH_PACKAGES_TOKEN;
5
+ Object.assign(env, extraEnv);
6
+ delete env.GITHUB_TOKEN;
7
+ delete env.PRIVATE_KEY;
8
+ env.GIT_TERMINAL_PROMPT = '0';
9
+ env.DD_API_KEY = env.DD_API_KEY ?? env.DATADOG_API_KEY;
10
+ env.DD_APP_KEY = env.DD_APP_KEY ?? env.DATADOG_APP_KEY;
11
+ env.DD_SITE = env.DD_SITE?.trim() || env.DATADOG_SITE?.trim() || 'datadoghq.com';
12
+ return env;
13
+ }
14
+ export function approvedSkillArgs(env = process.env) {
15
+ if (!env.SANDBOX_PROFILE)
16
+ return [];
17
+ const skills = ['/app/apps/sandbox/skills/investigation/SKILL.md'];
18
+ if (env.SANDBOX_PROFILE === 'android') {
19
+ skills.push('/app/apps/sandbox/skills/agent-device/SKILL.md');
20
+ }
21
+ return skills.flatMap((skill) => ['--skill', skill]);
22
+ }
@@ -9,6 +9,7 @@ import { CX_HERO_MANUAL_HANDLE, resolveHeroGroup } from './hero-group-map.js';
9
9
  import { AUTOFIX_FAILURE_MARKER, FIX_LOOP_START_MARKER, HERO_AUTOFIX_MARKER } from './markers.js';
10
10
  import { invokeTriageModel } from './model.js';
11
11
  import { commitAndPushFix, findPRTemplate, openDraftFixPR } from './pr-creator.js';
12
+ import { sandboxEnvironmentError } from './sandbox-environment.js';
12
13
  const FIX_PROMPT_FILE = 'issue-triage-fix-prompt.md';
13
14
  // Maximum time allowed for the Codex fix-generation invocation before we abort
14
15
  const AUTO_FIX_CODEX_TIMEOUT_MS = 45 * 60 * 1000;
@@ -330,6 +331,9 @@ export async function runFixCore(input) {
330
331
  reason: buildFixAssessmentSkipReason(fixAssessment),
331
332
  };
332
333
  }
334
+ const environmentError = sandboxEnvironmentError(targetRepo.repository.fullName);
335
+ if (environmentError)
336
+ return { ok: false, reason: environmentError };
333
337
  const capabilityEnvResult = resolveAutofixCapabilityEnv(targetRepo.repository.fullName);
334
338
  if (!capabilityEnvResult.ok) {
335
339
  return capabilityEnvResult;
@@ -0,0 +1,53 @@
1
+ import { repositorySandboxProfile, SANDBOX_PROFILES } from 'doistbot-issue-routing';
2
+ function readSelection() {
3
+ if (!process.env.SANDBOX_SELECTION)
4
+ return undefined;
5
+ const selection = JSON.parse(process.env.SANDBOX_SELECTION);
6
+ function strings(value) {
7
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
8
+ }
9
+ if (!selection ||
10
+ !strings(selection.repositories) ||
11
+ !strings(selection.labels) ||
12
+ !strings(selection.verificationLimits) ||
13
+ typeof selection.reason !== 'string' ||
14
+ (selection.profile !== 'legacy' &&
15
+ (!SANDBOX_PROFILES.includes(selection.profile) || typeof selection.image !== 'string')))
16
+ throw new Error('Invalid sandbox selection.');
17
+ return selection;
18
+ }
19
+ /** Check image identity only. Repo setup and required checks belong to issue #524. */
20
+ export function sandboxEnvironmentError(repository) {
21
+ let selection;
22
+ try {
23
+ selection = readSelection();
24
+ }
25
+ catch {
26
+ return 'Invalid sandbox selection.';
27
+ }
28
+ if (!selection)
29
+ return undefined;
30
+ if (!selection.repositories.some((candidate) => candidate.toLowerCase() === repository.toLowerCase()))
31
+ return 'Fix target is outside the recorded sandbox scope.';
32
+ // Mixed scopes deliberately retain the existing image and autofix behavior.
33
+ if (selection.profile === 'legacy')
34
+ return undefined;
35
+ const profile = repositorySandboxProfile(repository, selection.labels);
36
+ if (selection.profile !== profile ||
37
+ process.env.SANDBOX_PROFILE !== profile ||
38
+ process.env.SANDBOX_IMAGE_ID !== selection.image)
39
+ return 'Unsupported fix environment: target needs a different image. Cross-image fix dispatch is not enabled.';
40
+ return undefined;
41
+ }
42
+ export function describeSandboxEnvironment() {
43
+ let selection;
44
+ try {
45
+ selection = readSelection();
46
+ }
47
+ catch {
48
+ return '\n\nSandbox selection is invalid; automatic fixes are unavailable.';
49
+ }
50
+ if (!selection)
51
+ return '';
52
+ return `\n\n## Sandbox environment\nProfile: ${selection.profile}. ${selection.reason}\n${selection.verificationLimits.join('\n')}\nInstalled tools do not prove that repository builds or tests passed. Report only checks actually performed.`;
53
+ }
@@ -17,6 +17,7 @@ import { fillTemplate, loadPromptTemplate } from './prompt.js';
17
17
  import { allRepositoriesAllow, createRepoConfigLoader, isAutoFixEnabled, isAutoTriageEnabled, } from './repo-config.js';
18
18
  import { resolveTriageScope } from './resolution.js';
19
19
  import { buildHeroRoutingComment, postTriageRouting, shouldPostHeroRoutingComment, } from './routing.js';
20
+ import { describeSandboxEnvironment } from './sandbox-environment.js';
20
21
  import { createGitHubClients, getIssueUrl, prepareWorkspaceReposDirectory } from './shared.js';
21
22
  import { deleteAutoTriagePlaceholder, postTriageOutput } from './side-effects.js';
22
23
  const MAX_ISSUE_BODY_CHARS = 8_000;
@@ -238,7 +239,7 @@ async function runIssueTriagePipeline(ctx, mode) {
238
239
  type: 'installation',
239
240
  installationId: ctx.installationId,
240
241
  }));
241
- const prompt = buildPrompt({
242
+ let prompt = buildPrompt({
242
243
  ctx,
243
244
  issue,
244
245
  comments,
@@ -247,6 +248,7 @@ async function runIssueTriagePipeline(ctx, mode) {
247
248
  decisionPath: resolution.decisionPath,
248
249
  enableAutoFix,
249
250
  });
251
+ prompt += describeSandboxEnvironment();
250
252
  const triageResult = await invokeTriageModel({
251
253
  prompt,
252
254
  apiKey: ctx.openaiApiKey,
@@ -7,6 +7,8 @@ export type LoggerOptions = {
7
7
  suppressLogs?: (level: LogLevel) => boolean;
8
8
  suppressInfoLogs?: () => boolean;
9
9
  includeLogField?: boolean;
10
+ /** Receives serialized JSON without a trailing newline. The writer must add one. */
11
+ write?: (line: string) => void;
10
12
  buildInfo?: {
11
13
  version: number;
12
14
  commit: string;
@@ -33,8 +33,14 @@ export function createLogger(options = {}) {
33
33
  }
34
34
  Object.assign(payload, traceInfo);
35
35
  payload.meta = metaInfo;
36
- // eslint-disable-next-line no-console
37
- console.log(JSON.stringify(payload));
36
+ const line = JSON.stringify(payload);
37
+ if (options.write) {
38
+ options.write(line);
39
+ }
40
+ else {
41
+ // eslint-disable-next-line no-console
42
+ console.log(line);
43
+ }
38
44
  }
39
45
  return {
40
46
  info: (message, meta) => log('info', message, meta),
@@ -73,6 +73,8 @@ Any state that must survive restarts must be stored externally, e.g.:
73
73
 
74
74
  Local disk may be used only for temporary data.
75
75
 
76
+ Durable state must be backed up. DynamoDB tables must have [point-in-time recovery](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Point-in-time-recovery.html) enabled and carry the account's `Backup` tag so the AWS Backup plan selects them. Caches, lock tables and short-lived session tables with a TTL are exempt.
77
+
76
78
  ## Resource Limits
77
79
 
78
80
  Define expected CPU, memory, network, and local disk usage.