@ai-sdk/harness-claude-code 1.0.0-canary.2 → 1.0.0-canary.4

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.
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import {
5
6
  commonTool,
@@ -19,9 +20,13 @@ import {
19
20
  type HarnessV1Skill,
20
21
  type HarnessV1StreamPart,
21
22
  } from '@ai-sdk/harness';
22
- import { classifyDiskLog, SandboxChannel } from '@ai-sdk/harness/utils';
23
23
  import {
24
- safeParseJSON,
24
+ classifyDiskLog,
25
+ markBridgeStarting,
26
+ SandboxChannel,
27
+ waitForBridgeReady,
28
+ } from '@ai-sdk/harness/utils';
29
+ import {
25
30
  tool,
26
31
  type Experimental_SandboxSession,
27
32
  type Experimental_SandboxProcess,
@@ -33,7 +38,6 @@ import {
33
38
  type ClaudeCodeAuthOptions,
34
39
  } from './claude-code-auth';
35
40
  import {
36
- bridgeReadySchema,
37
41
  outboundMessageSchema,
38
42
  type InboundMessage,
39
43
  type OutboundMessage,
@@ -506,6 +510,7 @@ export function createClaudeCode(
506
510
  sandboxId,
507
511
  debug: startOpts.observability?.debug,
508
512
  permissionMode: startOpts.permissionMode,
513
+ skills: startOpts.skills ?? [],
509
514
  });
510
515
  } catch {
511
516
  // Bridge no longer reachable — recover by respawning below.
@@ -536,12 +541,20 @@ export function createClaudeCode(
536
541
  }
537
542
  }
538
543
 
544
+ const sandboxHomeDir =
545
+ startOpts.skills && startOpts.skills.length > 0
546
+ ? await resolveSandboxHomeDir({
547
+ sandbox: session,
548
+ abortSignal: startOpts.abortSignal,
549
+ })
550
+ : undefined;
539
551
  const port = resolveBridgePort(sandboxSession, settings.port);
540
552
  const token = randomBytes(32).toString('hex');
541
553
  const env = {
542
554
  ...resolveClaudeCodeEnv(settings.auth),
543
555
  BRIDGE_CHANNEL_TOKEN: token,
544
556
  BRIDGE_WS_PORT: String(port),
557
+ ...(sandboxHomeDir ? { HOME: sandboxHomeDir } : {}),
545
558
  ...(respawnStrategy === 'replay'
546
559
  ? { BRIDGE_REPLAY_FROM_DISK: '1' }
547
560
  : {}),
@@ -560,15 +573,25 @@ export function createClaudeCode(
560
573
  });
561
574
 
562
575
  if (startOpts.skills && startOpts.skills.length > 0) {
576
+ if (!sandboxHomeDir) {
577
+ throw new Error('Unable to resolve sandbox HOME directory.');
578
+ }
563
579
  await writeSkills({
564
580
  sandbox: session,
565
- workdir: workDir,
581
+ homeDir: sandboxHomeDir,
566
582
  skills: startOpts.skills,
567
583
  abortSignal: startOpts.abortSignal,
568
584
  });
569
585
  }
570
586
  }
571
587
 
588
+ await markBridgeStarting({
589
+ sandbox: session,
590
+ bridgeStateDir,
591
+ bridgeType: 'claude-code',
592
+ abortSignal: startOpts.abortSignal,
593
+ });
594
+
572
595
  const proc = await session.spawn({
573
596
  command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${workDir} --bridge-state-dir ${bridgeStateDir}`,
574
597
  env,
@@ -589,10 +612,27 @@ export function createClaudeCode(
589
612
  void bridgeStartupStderrDone;
590
613
  const { port: boundPort } = await waitForBridgeReady({
591
614
  proc,
615
+ sandbox: session,
616
+ bridgeStateDir,
617
+ bridgeType: 'claude-code',
592
618
  timeoutMs,
593
619
  abortSignal: startOpts.abortSignal,
594
- stderrTail: bridgeStartupStderr,
595
- stderrDone: bridgeStartupStderrDone,
620
+ createTimeoutError: ({ proc, stdoutTail }) =>
621
+ createBridgeStartupError({
622
+ message: 'claude-code bridge did not become ready in time.',
623
+ proc,
624
+ stdoutTail,
625
+ stderrTail: bridgeStartupStderr,
626
+ stderrDone: bridgeStartupStderrDone,
627
+ }),
628
+ createExitError: ({ proc, stdoutTail }) =>
629
+ createBridgeStartupError({
630
+ message: 'claude-code bridge exited before becoming ready.',
631
+ proc,
632
+ stdoutTail,
633
+ stderrTail: bridgeStartupStderr,
634
+ stderrDone: bridgeStartupStderrDone,
635
+ }),
596
636
  });
597
637
  void drainRest(proc.stdout);
598
638
 
@@ -632,6 +672,7 @@ export function createClaudeCode(
632
672
  sandboxId,
633
673
  debug: startOpts.observability?.debug,
634
674
  permissionMode: startOpts.permissionMode,
675
+ skills: startOpts.skills ?? [],
635
676
  });
636
677
  },
637
678
  };
@@ -652,31 +693,106 @@ function resolveBridgePort(
652
693
  }
653
694
 
654
695
  /**
655
- * Materialise skill files into `${workdir}/.claude/skills/<name>.md`. The
656
- * `claude` CLI auto-discovers skills from that directory on startup, so the
657
- * files have to be in place before the bridge is spawned. Each file uses
658
- * the YAML-frontmatter shape the CLI expects.
696
+ * Materialise skill files into
697
+ * `$HOME/.claude/skills/<name>/SKILL.md`. The `claude` CLI
698
+ * auto-discovers skills from that directory on startup, so the files have to
699
+ * be in place before the bridge is spawned without mutating the session
700
+ * workdir. Each file uses the YAML-frontmatter shape the CLI expects.
659
701
  */
660
702
  async function writeSkills({
661
703
  sandbox,
662
- workdir,
704
+ homeDir,
663
705
  skills,
664
706
  abortSignal,
665
707
  }: {
666
708
  sandbox: Experimental_SandboxSession;
667
- workdir: string;
709
+ homeDir: string;
668
710
  skills: ReadonlyArray<HarnessV1Skill>;
669
711
  abortSignal?: AbortSignal;
670
712
  }): Promise<void> {
713
+ for (const skill of skills) {
714
+ safeClaudeSkillName(skill.name);
715
+ for (const file of skill.files ?? []) {
716
+ safeClaudeSkillFilePath({
717
+ skillName: skill.name,
718
+ filePath: file.path,
719
+ });
720
+ }
721
+ }
722
+
671
723
  await sandbox.run({
672
- command: `mkdir -p ${workdir}/.claude/skills`,
724
+ command: `mkdir -p ${shellQuote(homeDir)}/.claude/skills`,
673
725
  abortSignal,
674
726
  });
675
727
  for (const skill of skills) {
676
- const path = `${workdir}/.claude/skills/${skill.name}.md`;
728
+ const name = safeClaudeSkillName(skill.name);
729
+ const skillDir = `${homeDir}/.claude/skills/${name}`;
730
+ const path = `${skillDir}/SKILL.md`;
677
731
  const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}\n`;
678
732
  await sandbox.writeTextFile({ path, content, abortSignal });
733
+ for (const file of skill.files ?? []) {
734
+ const filePath = safeClaudeSkillFilePath({
735
+ skillName: skill.name,
736
+ filePath: file.path,
737
+ });
738
+ await sandbox.writeTextFile({
739
+ path: `${skillDir}/${filePath}`,
740
+ content: file.content,
741
+ abortSignal,
742
+ });
743
+ }
744
+ }
745
+ }
746
+
747
+ async function resolveSandboxHomeDir({
748
+ sandbox,
749
+ abortSignal,
750
+ }: {
751
+ sandbox: Experimental_SandboxSession;
752
+ abortSignal?: AbortSignal;
753
+ }): Promise<string> {
754
+ const result = await sandbox.run({
755
+ command: 'printf "%s" "$HOME"',
756
+ abortSignal,
757
+ });
758
+ const homeDir = result.stdout.trim();
759
+ if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
760
+ throw new Error(
761
+ `Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
762
+ );
763
+ }
764
+ return homeDir;
765
+ }
766
+
767
+ function safeClaudeSkillName(name: string): string {
768
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
769
+ throw new Error(`Invalid Claude Code skill name: ${name}`);
679
770
  }
771
+ return name;
772
+ }
773
+
774
+ function safeClaudeSkillFilePath({
775
+ skillName,
776
+ filePath,
777
+ }: {
778
+ skillName: string;
779
+ filePath: string;
780
+ }): string {
781
+ const normalized = path.posix.normalize(filePath);
782
+ if (
783
+ normalized === '.' ||
784
+ normalized.startsWith('../') ||
785
+ path.posix.isAbsolute(normalized)
786
+ ) {
787
+ throw new Error(
788
+ `Invalid Claude Code skill file path for ${skillName}: ${filePath}`,
789
+ );
790
+ }
791
+ return normalized;
792
+ }
793
+
794
+ function shellQuote(value: string): string {
795
+ return `'${value.replace(/'/g, `'\\''`)}'`;
680
796
  }
681
797
 
682
798
  async function readBridgeAsset(name: string): Promise<string> {
@@ -697,80 +813,6 @@ async function readBridgeAsset(name: string): Promise<string> {
697
813
  throw lastErr ?? new Error(`bridge asset not found: ${name}`);
698
814
  }
699
815
 
700
- async function waitForBridgeReady({
701
- proc,
702
- timeoutMs,
703
- abortSignal,
704
- stderrTail,
705
- stderrDone,
706
- }: {
707
- proc: Experimental_SandboxProcess;
708
- timeoutMs: number;
709
- abortSignal: AbortSignal | undefined;
710
- stderrTail: string[];
711
- stderrDone: Promise<void>;
712
- }): Promise<{ port: number }> {
713
- const reader = proc.stdout.pipeThrough(new TextDecoderStream()).getReader();
714
-
715
- const decoder = lineDecoder();
716
- const stdoutTail: string[] = [];
717
-
718
- const deadline = Date.now() + timeoutMs;
719
- try {
720
- while (true) {
721
- if (abortSignal?.aborted) {
722
- await proc.kill();
723
- throw abortSignal.reason ?? new DOMException('Aborted', 'AbortError');
724
- }
725
- const remaining = deadline - Date.now();
726
- if (remaining <= 0) {
727
- await proc.kill();
728
- throw await createBridgeStartupError({
729
- message: 'claude-code bridge did not become ready in time.',
730
- proc,
731
- stdoutTail,
732
- stderrTail,
733
- stderrDone,
734
- });
735
- }
736
- const { value, done } = (await Promise.race([
737
- reader.read(),
738
- new Promise(resolve =>
739
- setTimeout(
740
- () => resolve({ value: undefined, done: false }),
741
- remaining,
742
- ),
743
- ),
744
- ])) as ReadableStreamReadResult<string>;
745
- if (done) {
746
- for (const line of decoder.flush()) {
747
- stdoutTail.push(line);
748
- if (stdoutTail.length > 20) stdoutTail.shift();
749
- }
750
- throw await createBridgeStartupError({
751
- message: 'claude-code bridge exited before becoming ready.',
752
- proc,
753
- stdoutTail,
754
- stderrTail,
755
- stderrDone,
756
- });
757
- }
758
- if (value === undefined) continue;
759
- for (const line of decoder.push(value)) {
760
- stdoutTail.push(line);
761
- if (stdoutTail.length > 20) stdoutTail.shift();
762
- const parsed = await safeParseJSON({
763
- text: line,
764
- schema: bridgeReadySchema,
765
- });
766
- if (parsed.success) return { port: parsed.value.port };
767
- }
768
- }
769
- } finally {
770
- reader.releaseLock();
771
- }
772
- }
773
-
774
816
  async function createBridgeStartupError({
775
817
  message,
776
818
  proc,
@@ -1063,6 +1105,7 @@ function createSession({
1063
1105
  sandboxId,
1064
1106
  debug,
1065
1107
  permissionMode,
1108
+ skills,
1066
1109
  }: {
1067
1110
  sessionId: string;
1068
1111
  channel: ClaudeCodeChannel;
@@ -1079,6 +1122,7 @@ function createSession({
1079
1122
  sandboxId: string;
1080
1123
  debug: HarnessV1DebugConfig | undefined;
1081
1124
  permissionMode: HarnessV1PermissionMode | undefined;
1125
+ skills: ReadonlyArray<HarnessV1Skill>;
1082
1126
  }): HarnessV1Session {
1083
1127
  let stopped = false;
1084
1128
  let stopPromise: Promise<void> | undefined;
@@ -1259,6 +1303,9 @@ function createSession({
1259
1303
  model,
1260
1304
  maxTurns,
1261
1305
  thinking,
1306
+ ...(skills.length > 0
1307
+ ? { skills: skills.map(skill => skill.name) }
1308
+ : {}),
1262
1309
  ...(permissionMode ? { permissionMode } : {}),
1263
1310
  ...(debug ? { debug } : {}),
1264
1311
  ...(pendingResumeFlag ? { continue: true } : {}),
@@ -1307,6 +1354,9 @@ function createSession({
1307
1354
  model,
1308
1355
  maxTurns,
1309
1356
  thinking,
1357
+ ...(skills.length > 0
1358
+ ? { skills: skills.map(skill => skill.name) }
1359
+ : {}),
1310
1360
  ...(permissionMode ? { permissionMode } : {}),
1311
1361
  ...(debug ? { debug } : {}),
1312
1362
  continue: true,