feat(node): fix CLI adapter and session management, archive G07 task

- Fix CLI adapter files for proper session/workspace handling
- Fix session.go and session_test.go
- Archive 03+02_cli_agent_cwd task to archive
This commit is contained in:
toki 2026-06-13 20:36:48 +09:00
parent 423264feb2
commit cd5425e89a
16 changed files with 1111 additions and 152 deletions

View file

@ -0,0 +1,292 @@
<!-- task=m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-13
task=m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `agent-cwd`: workspace-bound agent process 실행기가 `ExecutionSpec.Workspace`를 process working directory로 적용한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Apply Workspace To CLI Process Starts | [x] |
## 구현 체크리스트
- [x] predecessor `02+01_workspace_required_enforcement`의 `complete.log`가 archive 위치에 있는지 확인한다.
- [x] one-shot/codex-exec 공통 `executeCommand`가 `ExecutionSpec.Workspace`를 `exec.Cmd.Dir`로 적용하고, 빈 workspace에서는 기존 cwd 동작을 유지한다.
- [x] persistent non-terminal과 terminal session start가 workspace를 process cwd로 적용한다.
- [x] codex app-server와 opencode owned server process start가 workspace를 process cwd로 적용한다. opencode `--dir`가 명시된 경우 기존 explicit dir 우선순위를 유지한다.
- [x] cwd 적용 테스트를 `apps/node/internal/adapters/cli`와 `apps/node/internal/terminal`에 추가한다.
- [x] `go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli`와 `./scripts/e2e-smoke.sh`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW(으)로 승격 시 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
변경 없음.
## 주요 설계 결정
1. CLI.Start 시점 등 ExecutionSpec이 존재하지 않는 context에서는 workspace string으로 빈 문자열을 전달하여 기존 cwd 동작이 유지되도록 설계함.
2. opencode의 경우 explicit하게 --dir가 명시되었을 때 우선하고, 명시되지 않은 경우 spec.Workspace를 cwd로 적용하도록 설계함.
3. terminal.Options 구조체에 Dir 필드를 추가하고 pty.StartWithSize 실행 시 cmd.Dir에 매핑되도록 지원함.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `ExecutionSpec.Workspace`가 모든 CLI process start 경로에서 cwd로 적용되는지 확인한다.
- 빈 workspace에서 기존 cwd 동작이 유지되는지 확인한다.
- `session-workspace` 범위인 session key 변경을 이 plan에서 섞지 않았는지 확인한다.
## 검증 결과
### API-1 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
ok iop/apps/node/internal/terminal 0.552s
ok iop/apps/node/internal/adapters/cli 45.537s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
ok iop/apps/node/internal/terminal 0.552s
ok iop/apps/node/internal/adapters/cli 45.537s
$ ./scripts/e2e-smoke.sh
[e2e] NOTE: auxiliary smoke only; completion requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
[e2e] shellcheck not found, skipping
[e2e] prompt templates: first=yes-short second=hello-basic background=hello-formal base=10
[e2e] preparing honest mock smoke test (using scripted cli adapter)...
[e2e] starting smoke test (profile: mock, port: 30358, persistent: 1, has_status: 0)
[e2e] waiting for node registration (timeout: 60s)
[e2e] > /nodes
[e2e] > /capabilities
[e2e] > /transport
[e2e] > 간단한 긍정 응답 확인입니다. 정확히 IOP_E2E_YES_SHORT 만 출력하세요.
[e2e] > 짧은 인사 확인입니다. 답변은 정확히 IOP_E2E_HELLO_BASIC 하나만 출력하세요.
[e2e] > /session session2
[e2e] > /background on
[e2e] > 안녕하세요에 대한 응답 확인입니다. 다른 말 없이 IOP_E2E_HELLO_FORMAL 만 답하세요.
[e2e] > /background off
[e2e] > /sessions
[e2e] > /terminate-session
[e2e] > /exit
=== EDGE OUTPUT ===
[edge] config=/tmp/tmp.UJ7zlY2J1c/edge.yaml
IOP Edge console listening on 127.0.0.1:30358
Console target node= adapter=cli target=fake-cli session=default background=false
Start node.sh on another host, then type a message here.
Commands: /nodes, /node <id|alias>, /session <id>, /background on|off, /terminate-session, /status, /capabilities, /sessions, /transport, /exit
edge> [node0-evt] connected reason="registered"
node0 = test-node (test-node)
edge> [node0-capabilities] adapter=cli target=fake-cli session=default
adapter = cli
instance_key =
max_concurrency = 4
targets = fake-cli
edge> [node0-transport] adapter=cli target=fake-cli session=default
adapter = cli
connected = true
node_id = test-node
session_id = default
state = connected
target = fake-cli
edge> [edge] sent run_id=manual-1781345432143175920 node=node0 adapter=cli target=fake-cli session=default background=false
[node0-evt] start run_id=manual-1781345432143175920
[node0-msg] IOP_E2E_YES_SHORT
[node0-msg] IOP_E2E_YES_SHORT_TAIL
[node0-evt] complete run_id=manual-1781345432143175920 detail="idle-timeout"
edge> [edge] sent run_id=manual-1781345433364632837 node=node0 adapter=cli target=fake-cli session=default background=false
[node0-evt] start run_id=manual-1781345433364632837
[node0-msg] IOP_E2E_HELLO_BASIC
[node0-msg] IOP_E2E_HELLO_BASIC_TAIL
[node0-evt] complete run_id=manual-1781345433364632837 detail="idle-timeout"
edge> session → session2
edge> background → on
edge> [edge] sent run_id=manual-1781345435002741463 node=node0 adapter=cli target=fake-cli session=session2 background=true
[edge] background run dispatched, events will arrive asynchronously
edge> [node0-evt] start run_id=manual-1781345435002741463 session=session2 background=true
[node0-msg] IOP_E2E_HELLO_FORMAL
[node0-msg] IOP_E2E_HELLO_FORMAL_TAIL
[node0-evt] complete run_id=manual-1781345435002741463 detail="idle-timeout"
background → off
edge> [node0-sessions] adapter=cli target=fake-cli session=session2
sessions: 2
[0] mode=persistent target=fake-cli session=default
[1] mode=persistent target=fake-cli session=session2
edge> terminated session session2 node=node0
edge> bye
=== NODE OUTPUT ===
[node] config=/tmp/tmp.UJ7zlY2J1c/node.yaml
[node] waiting for edge at 127.0.0.1:30358 timeout=30s
[node] edge is reachable
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] PROVIDE *config.NodeConfig <= iop/apps/node/internal/bootstrap.Module.func1()
[Fx] PROVIDE *zap.Logger <= iop/apps/node/internal/bootstrap.Module.func2()
[Fx] INVOKE iop/apps/node/internal/bootstrap.Module.func3()
[Fx] RUN provide: go.uber.org/fx.New.func1()
[Fx] RUN provide: iop/apps/node/internal/bootstrap.Module.func1()
[Fx] RUN provide: iop/apps/node/internal/bootstrap.Module.func2()
[Fx] HOOK OnStart iop/apps/node/internal/bootstrap.Module.func3.1() executing (caller: iop/apps/node/internal/bootstrap.Module.func3)
{"level":"info","ts":1781345431.2836273,"caller":"transport/client.go:66","msg":"registered with edge","node_id":"test-node","alias":"test-node"}
{"level":"info","ts":1781345431.2877934,"caller":"store/store.go:62","msg":"store ready","dsn":"file:/tmp/tmp.UJ7zlY2J1c/workspace/iop.db?cache=shared&mode=rwc"}
{"level":"info","ts":1781345431.2881181,"caller":"cli/cli.go:226","msg":"cli adapter: persistent session started","target":"fake-cli"}
[Fx] HOOK OnStart iop/apps/node/internal/bootstrap.Module.func3.1() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 107.85175ms
[Fx] RUNNING
{"level":"info","ts":1781345431.710501,"caller":"node/node.go:277","msg":"command request","request_id":"caps-1781345431710126170","type":"NODE_COMMAND_TYPE_CAPABILITIES","adapter":"cli","target":"fake-cli"}
{"level":"info","ts":1781345431.9181707,"caller":"node/node.go:277","msg":"command request","request_id":"transport-1781345431917941003","type":"NODE_COMMAND_TYPE_TRANSPORT_STATUS","adapter":"cli","target":"fake-cli"}
{"level":"info","ts":1781345432.1436744,"caller":"node/node.go:72","msg":"run request received","run_id":"manual-1781345432143175920","adapter":"cli","target":"fake-cli"}
[edge-message] 간단한 긍정 응답 확인입니다. 정확히 IOP_E2E_YES_SHORT 만 출력하세요.
[node-event] start run_id=manual-1781345432143175920
[node-message] IOP_E2E_YES_SHORT
IOP_E2E_YES_SHORT_TAIL
[node-event] complete run_id=manual-1781345432143175920 detail="idle-timeout"
{"level":"info","ts":1781345433.3648756,"caller":"node/node.go:72","msg":"run request received","run_id":"manual-1781345433364632837","adapter":"cli","target":"fake-cli"}
[edge-message] 짧은 인사 확인입니다. 답변은 정확히 IOP_E2E_HELLO_BASIC 하나만 출력하세요.
[node-event] start run_id=manual-1781345433364632837
[node-message] IOP_E2E_HELLO_BASIC
IOP_E2E_HELLO_BASIC_TAIL
[node-event] complete run_id=manual-1781345433364632837 detail="idle-timeout"
{"level":"info","ts":1781345435.0030549,"caller":"node/node.go:72","msg":"run request received","run_id":"manual-1781345435002741463","adapter":"cli","target":"fake-cli"}
[edge-message] 안녕하세요에 대한 응답 확인입니다. 다른 말 없이 IOP_E2E_HELLO_FORMAL 만 답하세요.
[node-event] start run_id=manual-1781345435002741463
[node-message] IOP_E2E_HELLO_FORMAL
IOP_E2E_HELLO_FORMAL_TAIL
[node-event] complete run_id=manual-1781345435002741463 detail="idle-timeout"
{"level":"info","ts":1781345436.43056,"caller":"node/node.go:277","msg":"command request","request_id":"sessions-1781345436430345214","type":"NODE_COMMAND_TYPE_SESSION_LIST","adapter":"cli","target":"fake-cli"}
{"level":"info","ts":1781345436.6392963,"caller":"node/node.go:256","msg":"cancel request","run_id":"","action":"CANCEL_ACTION_TERMINATE_SESSION"}
{"level":"info","ts":1781345436.8482554,"caller":"transport/session.go:89","msg":"disconnected from edge","transport_close_reason":"remote_closed","transport_close_error":"EOF"}
[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF"
[Fx] TERMINATED
[Fx] HOOK OnStop iop/apps/node/internal/bootstrap.Module.func3.2() executing (caller: iop/apps/node/internal/bootstrap.Module.func3)
[Fx] HOOK OnStop iop/apps/node/internal/bootstrap.Module.func3.2() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 112.25µs
===================
[e2e] Auxiliary smoke test PASSED.
[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus areas from the plan. |
| 검증 결과 | Implementing agent | Paste actual stdout/stderr for the commands above. |
## 코드리뷰 결과
### 종합 판정
FAIL
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Pass | 변경된 process start 경로는 `ExecutionSpec.Workspace`를 `cmd.Dir`/`terminal.Options.Dir`에 전달한다. |
| Completeness | Fail | 계획이 요구한 codex app-server/opencode owned server cwd 검증과 testing rule의 full-cycle 실제 구동 증거가 빠졌다. |
| Test coverage | Fail | one-shot/persistent/terminal cwd 테스트는 있으나 codex app-server와 opencode owned server cwd 테스트가 없다. |
| API contract | Pass | `metadata.workspace` -> `ExecutionSpec.Workspace` -> process cwd 방향의 계약 자체를 깨는 변경은 발견하지 못했다. |
| Code quality | Pass | debug print, dead code, unrelated source churn은 발견하지 못했다. |
| Plan deviation | Fail | review stub은 모든 체크리스트를 완료로 표시했지만 계획의 provider server process test 범위를 충족하지 못했다. |
| Verification trust | Fail | 재실행한 Go test와 auxiliary smoke는 통과했지만, smoke 출력 자체가 dev edge/node user-flow 검증을 완료 조건으로 남긴다. |
Reviewer verification rerun:
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
ok iop/apps/node/internal/terminal 0.543s
ok iop/apps/node/internal/adapters/cli 45.494s
$ ./scripts/e2e-smoke.sh
[e2e] Auxiliary smoke test PASSED.
[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
$ git diff --check
# PASS
```
### 발견된 문제
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:383`와 `apps/node/internal/adapters/cli/opencode_sse.go:271`에 workspace cwd 적용 로직은 추가됐지만, 계획의 "codex app-server와 opencode owned server process start가 workspace를 process cwd로 적용" 항목을 직접 검증하는 테스트가 없다. `codex_app_server_internal_test.go`와 `opencode_sse_internal_test.go` 또는 동등한 CLI adapter test에 fake executable/server를 추가해 workspace cwd와 opencode explicit `--dir` 우선순위를 검증한다.
- Required: `agent-task/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/CODE_REVIEW-local-G07.md:116`의 최종 검증은 auxiliary `./scripts/e2e-smoke.sh`까지만 기록되어 있고, 같은 출력 `:117`도 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow 검증이 필요하다고 명시한다. `agent-ops/skills/project/e2e-smoke/SKILL.md` 기준으로 repo 내부 edge-node 진단을 실행하고, 메시지 2회, node local payload와 edge rendered payload 일치, `/nodes`, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 결과를 실제 출력으로 남긴다.
### 다음 단계
FAIL follow-up: 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성해 누락된 provider server cwd tests와 repo 내부 edge-node 진단 증거를 보완한다.

View file

@ -0,0 +1,277 @@
<!-- task=m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-13
task=m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `agent-cwd`: workspace-bound agent process 실행기가 `ExecutionSpec.Workspace`를 process working directory로 적용한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Add Provider Server Cwd Tests | [x] |
| [REVIEW_API-2] Capture Required Edge-Node Diagnostic Evidence | [x] |
## 구현 체크리스트
- [x] codex app-server process start가 workspace cwd를 쓰는 deterministic test를 추가한다.
- [x] opencode owned server process start가 workspace cwd를 쓰고 explicit `--dir`를 우선하는 deterministic test를 추가한다.
- [x] repo 내부 edge-node 진단을 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`로 실행하고 메시지 2회, node local payload와 edge rendered payload 일치, `/nodes`, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 결과를 기록한다.
- [x] `go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli`, `./scripts/e2e-smoke.sh`, `git diff --check`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW(으)로 승격 시 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
변경 없음.
## 주요 설계 결정
1. codex app-server 및 opencode owned server process에 대해 테스트 코드 내에서 가상 실행 스크립트(fake script)를 동적으로 작성하고 실행 권한을 주어, 외부 binary나 network 의존 없이 CWD 적용 여부를 결정론적(deterministic)으로 검증하도록 설계함.
2. opencode의 경우 opts.Dir (explicit --dir)와 workspace 파라미터가 동시에 주어질 때 opts.Dir를 우선하여 cwd로 설정하는 동작을 두 개의 독립된 테스트 케이스로 격리하여 검증함.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- codex app-server와 opencode owned server process start cwd가 실제 fake process로 검증되는지 확인한다.
- opencode explicit `--dir`가 workspace보다 우선하는지 확인한다.
- repo 내부 edge-node 진단 증거가 auxiliary smoke와 구분되어 있고, 메시지 2회와 command 응답 기준을 충족하는지 확인한다.
- source behavior 변경이 테스트 보완 범위를 넘어가지 않았는지 확인한다.
## 검증 결과
### REVIEW_API-1 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/adapters/cli
ok iop/apps/node/internal/adapters/cli 45.917s
```
### REVIEW_API-2 중간 검증
```bash
$ IOP_EDGE_CONFIG=<temp-edge.yaml> ./scripts/dev/edge.sh
$ IOP_NODE_CONFIG=<temp-node.yaml> ./scripts/dev/node.sh
# edge/node transcript showing /nodes, message x2, /capabilities, /transport, /sessions, /terminate-session
[e2e] > /nodes
[e2e] > IOP_MANUAL_ONE
[e2e] > IOP_MANUAL_TWO
[e2e] > /capabilities
[e2e] > /transport
[e2e] > /sessions
[e2e] > /terminate-session
[e2e] > /exit
=== EDGE OUTPUT ===
[edge] config=/config/workspace/iop/tmp-untrusted-test/diag_cwd/edge.yaml
IOP Edge console listening on 127.0.0.1:31234
Console target node= adapter=cli target=fake-cli session=default background=false
Start node.sh on another host, then type a message here.
Commands: /nodes, /node <id|alias>, /session <id>, /background on|off, /terminate-session, /status, /capabilities, /sessions, /transport, /exit
edge> [node0-evt] connected reason="registered"
node0 = test-node (test-node)
edge> [edge] sent run_id=manual-1781346387825049377 node=node0 adapter=cli target=fake-cli session=default background=false
[node0-evt] start run_id=manual-1781346387825049377
[node0-msg] IOP_MANUAL_ONE
[node0-msg] IOP_MANUAL_ONE_TAIL
[node0-evt] complete run_id=manual-1781346387825049377 detail="idle-timeout"
edge> [edge] sent run_id=manual-1781346389326895753 node=node0 adapter=cli target=fake-cli session=default background=false
[node0-evt] start run_id=manual-1781346389326895753
[node0-msg] IOP_MANUAL_TWO
[node0-msg] IOP_MANUAL_TWO_TAIL
[node0-evt] complete run_id=manual-1781346389326895753 detail="idle-timeout"
edge> [node0-capabilities] adapter=cli target=fake-cli session=default
adapter = cli
instance_key =
max_concurrency = 4
targets = fake-cli
edge> [node0-transport] adapter=cli target=fake-cli session=default
adapter = cli
connected = true
node_id = test-node
session_id = default
state = connected
target = fake-cli
edge> [node0-sessions] adapter=cli target=fake-cli session=default
sessions: 1
[0] mode=persistent target=fake-cli session=default
edge> terminated session default node=node0
edge> bye
=== NODE OUTPUT ===
[node] config=/config/workspace/iop/tmp-untrusted-test/diag_cwd/node.yaml
[node] waiting for edge at 127.0.0.1:31234 timeout=30s
[node] edge is reachable
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] PROVIDE *config.NodeConfig <= iop/apps/node/internal/bootstrap.Module.func1()
[Fx] PROVIDE *zap.Logger <= iop/apps/node/internal/bootstrap.Module.func2()
[Fx] INVOKE iop/apps/node/internal/bootstrap.Module.func3()
[Fx] RUN provide: go.uber.org/fx.New.func1()
[Fx] RUN provide: iop/apps/node/internal/bootstrap.Module.func1()
[Fx] RUN provide: iop/apps/node/internal/bootstrap.Module.func2()
[Fx] HOOK OnStart iop/apps/node/internal/bootstrap.Module.func3.1() executing (caller: iop/apps/node/internal/bootstrap.Module.func3)
{"level":"info","ts":1781346384.064757,"caller":"transport/client.go:66","msg":"registered with edge","node_id":"test-node","alias":"test-node"}
{"level":"info","ts":1781346384.0718446,"caller":"store/store.go:62","msg":"store ready","dsn":"file:/config/workspace/iop/tmp-untrusted-test/diag_cwd/workspace/iop.db?cache=shared&mode=rwc"}
{"level":"info","ts":1781346384.073181,"caller":"cli/cli.go:226","msg":"cli adapter: persistent session started","target":"fake-cli"}
[Fx] HOOK OnStart iop/apps/node/internal/bootstrap.Module.func3.1() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 113.593958ms
[Fx] RUNNING
{"level":"info","ts":1781346387.825762,"caller":"node/node.go:72","msg":"run request received","run_id":"manual-1781346387825049377","adapter":"cli","target":"fake-cli"}
[edge-message] IOP_MANUAL_ONE
[node-event] start run_id=manual-1781346387825049377
[node-message] IOP_MANUAL_ONE
IOP_MANUAL_ONE_TAIL
[node-event] complete run_id=manual-1781346387825049377 detail="idle-timeout"
{"level":"info","ts":1781346389.3271575,"caller":"node/node.go:72","msg":"run request received","run_id":"manual-1781346389326895753","adapter":"cli","target":"fake-cli"}
[edge-message] IOP_MANUAL_TWO
[node-event] start run_id=manual-1781346389326895753
[node-message] IOP_MANUAL_TWO
IOP_MANUAL_TWO_TAIL
[node-event] complete run_id=manual-1781346389326895753 detail="idle-timeout"
{"level":"info","ts":1781346390.829387,"caller":"node/node.go:277","msg":"command request","request_id":"caps-1781346390829017295","type":"NODE_COMMAND_TYPE_CAPABILITIES","adapter":"cli","target":"fake-cli"}
{"level":"info","ts":1781346392.3316538,"caller":"node/node.go:277","msg":"command request","request_id":"transport-1781346392331361504","type":"NODE_COMMAND_TYPE_TRANSPORT_STATUS","adapter":"cli","target":"fake-cli"}
{"level":"info","ts":1781346393.8340967,"caller":"node/node.go:277","msg":"command request","request_id":"sessions-1781346393833903713","type":"NODE_COMMAND_TYPE_SESSION_LIST","adapter":"cli","target":"fake-cli"}
{"level":"info","ts":1781346395.3381782,"caller":"node/node.go:256","msg":"cancel request","run_id":"","action":"CANCEL_ACTION_TERMINATE_SESSION"}
{"level":"info","ts":1781346396.843783,"caller":"transport/session.go:89","msg":"disconnected from edge","transport_close_reason":"remote_closed","transport_close_error":"EOF"}
[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF"
```
### 최종 검증
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
ok iop/apps/node/internal/terminal 0.551s
ok iop/apps/node/internal/adapters/cli 45.855s
$ ./scripts/e2e-smoke.sh
[e2e] Auxiliary smoke test PASSED.
$ git diff --check
# PASS
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus areas from the plan. |
| 검증 결과 | Implementing agent | Paste actual stdout/stderr for the commands above. |
## 코드리뷰 결과
### 종합 판정
PASS
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Pass | `ExecutionSpec.Workspace`가 one-shot, codex-exec 공통 실행, persistent non-terminal/terminal, codex app-server, opencode owned server start 경로의 cwd로 연결된다. opencode explicit `--dir` 우선순위도 유지된다. |
| Completeness | Pass | 이전 FAIL의 Required 두 건이 모두 해소됐고 active review의 구현 체크리스트와 실제 diff가 일치한다. |
| Test coverage | Pass | one-shot/persistent/terminal/codex app-server/opencode owned server cwd 테스트가 추가됐으며 test list와 재실행으로 확인했다. |
| API contract | Pass | `metadata.workspace` -> `ExecutionSpec.Workspace` -> process cwd 계약을 해치는 변경은 발견하지 못했다. |
| Code quality | Pass | 새 helper와 테스트는 기존 패키지 패턴 안에 있고, 검증용 임시 산출물은 리뷰 중 정리했다. |
| Plan deviation | Pass | follow-up 범위인 테스트/검증 증거 보완에 머물렀고 session isolation 범위를 섞지 않았다. |
| Verification trust | Pass | reviewer가 Go 패키지 테스트, auxiliary smoke, `git diff --check`, test list를 재실행했고 active review의 repo 내부 edge-node 진단 transcript도 기준 항목을 충족한다. |
Reviewer verification rerun:
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
ok iop/apps/node/internal/terminal 0.552s
ok iop/apps/node/internal/adapters/cli 45.995s
$ ./scripts/e2e-smoke.sh
[e2e] Auxiliary smoke test PASSED.
[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
$ go test -list 'Cwd|OpencodeLocalServerCwd|CodexAppServerProcCwd' ./apps/node/internal/adapters/cli ./apps/node/internal/terminal
TestCLIOneshotExecutorCwd
TestCLIPersistentNonTerminalCwd
TestCLIPersistentTerminalCwd
TestCodexAppServerProcCwd
TestOpencodeLocalServerCwd_Workspace
TestOpencodeLocalServerCwd_DirOverride
TestTerminalSessionCwd
$ git diff --check
# PASS
```
### 발견된 문제
없음
### 다음 단계
PASS: `complete.log`를 작성하고 active task directory를 archive로 이동한다. task group이 `m-openai-workspace-agent-execution-contract`이므로 런타임 완료 이벤트 메타데이터를 보고하고 roadmap은 수정하지 않는다.

View file

@ -0,0 +1,46 @@
# Complete - m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd
## 완료 일시
2026-06-13
## 요약
CLI agent cwd 적용 작업을 2회 리뷰 루프로 검증했고 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | FAIL | codex app-server/opencode owned server cwd 테스트와 repo 내부 edge-node 진단 증거가 누락되어 follow-up으로 전환 |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | provider server cwd tests, opencode `--dir` 우선순위 테스트, repo 내부 edge-node 진단 증거가 보완됨 |
## 구현/정리 내용
- `ExecutionSpec.Workspace`를 one-shot/codex-exec 공통 command, persistent non-terminal, terminal session, codex app-server, opencode owned server process cwd로 적용했다.
- opencode owned server는 explicit `--dir`가 있으면 workspace보다 우선하도록 유지했다.
- one-shot, persistent non-terminal, terminal, codex app-server, opencode owned server workspace cwd 회귀 테스트를 추가했다.
- repo 내부 `scripts/dev/edge.sh` + `scripts/dev/node.sh` 진단 transcript로 메시지 2회와 command 응답을 확인했다.
## 최종 검증
- `go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli` - PASS; `ok iop/apps/node/internal/terminal 0.552s`, `ok iop/apps/node/internal/adapters/cli 45.995s`
- `./scripts/e2e-smoke.sh` - PASS; `[e2e] Auxiliary smoke test PASSED.`
- `go test -list 'Cwd|OpencodeLocalServerCwd|CodexAppServerProcCwd' ./apps/node/internal/adapters/cli ./apps/node/internal/terminal` - PASS; cwd test names include one-shot, persistent non-terminal, persistent terminal, codex app-server, opencode workspace, opencode dir override, and terminal session.
- `git diff --check` - PASS
- `scripts/dev/edge.sh` + `scripts/dev/node.sh` repo 내부 edge-node 진단 - PASS; transcript evidence in `code_review_local_G07_1.log` covers `/nodes`, two foreground messages, `/capabilities`, `/transport`, `/sessions`, and `/terminate-session`.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Completed task ids:
- `agent-cwd`: PASS; evidence=`agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/plan_local_G07_0.log`, `agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/code_review_local_G07_0.log`, `agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/code_review_local_G07_1.log`; verification=`go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli`, `./scripts/e2e-smoke.sh`, `scripts/dev/edge.sh + scripts/dev/node.sh`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,122 @@
<!-- task=m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd plan=1 tag=REVIEW_API -->
# Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
이 plan은 code-review FAIL follow-up이다. 구현 범위는 `code_review_local_G07_0.log`의 Required 두 건을 해소하는 데 한정한다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌로 막히면 사용자에게 직접 묻지 말고 `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 채운다.
## 배경
이전 루프는 `ExecutionSpec.Workspace`를 CLI process cwd로 전달하는 소스 변경과 일부 테스트를 추가했지만, codex app-server/opencode owned server process start의 cwd 적용을 직접 검증하지 못했고, 사용자 실행 파이프라인 변경에 필요한 repo 내부 edge-node 진단 증거도 남기지 못했다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 결정이 필요하면 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 active `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션을 채운다. 구현 에이전트는 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `agent-cwd`: workspace-bound agent process 실행기가 `ExecutionSpec.Workspace`를 process working directory로 적용한다.
- Completion mode: check-on-pass
## 리뷰 실패 요약
- `apps/node/internal/adapters/cli/codex_app_server.go:383`와 `apps/node/internal/adapters/cli/opencode_sse.go:271`의 cwd 적용 로직에 대한 provider server process 직접 테스트가 없다.
- `code_review_local_G07_0.log`의 최종 검증은 auxiliary `./scripts/e2e-smoke.sh`까지만 있고, testing rule이 요구하는 `scripts/dev/edge.sh` + `scripts/dev/node.sh` repo 내부 edge-node 진단 증거가 없다.
## 범위 결정 근거
이 follow-up은 테스트와 검증 증거 보완이다. `session_workspace_isolation` 범위인 session key 변경, session list/terminate semantics 변경은 여전히 이 subtask에서 다루지 않는다. 소스 동작 변경은 새 테스트가 실제 cwd 적용 결함을 드러낸 경우에만 최소 수정한다.
## 빌드 등급
build lane은 `local-G07`을 유지한다. Required 이슈가 명확하고 deterministic fake process 및 로컬 dev script 진단으로 재현 가능하다.
## 구현 체크리스트
- [ ] codex app-server process start가 workspace cwd를 쓰는 deterministic test를 추가한다.
- [ ] opencode owned server process start가 workspace cwd를 쓰고 explicit `--dir`를 우선하는 deterministic test를 추가한다.
- [ ] repo 내부 edge-node 진단을 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`로 실행하고 메시지 2회, node local payload와 edge rendered payload 일치, `/nodes`, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 결과를 기록한다.
- [ ] `go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli`, `./scripts/e2e-smoke.sh`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
이 directory 이름 `03+02_cli_agent_cwd`의 predecessor `02+01_workspace_required_enforcement`는 `agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/complete.log`로 이미 충족되어 있다.
### [REVIEW_API-1] Add Provider Server Cwd Tests
문제: 기존 테스트는 one-shot, persistent non-terminal, terminal cwd만 검증한다. codex app-server와 opencode owned server process start는 `cmd.Dir` 적용 로직이 있지만 직접 회귀 테스트가 없다.
해결 방법: 외부 CLI 계정이 필요 없는 fake executable을 테스트 안에서 만든다. fake executable은 시작 cwd를 marker file에 기록하고, codex app-server test는 process start 직후 marker를 확인한 뒤 proc를 닫는다. opencode test는 listen line을 stdout/stderr로 출력하는 fake server를 사용해 `startOpencodeLocalServer`가 반환되도록 만들고, workspace cwd와 explicit `--dir` 우선순위를 각각 확인한다.
수정 파일 및 체크리스트:
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`: `startCodexAppServerProc(..., workspace)`가 workspace cwd에서 fake process를 시작하는지 검증한다.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`: `startOpencodeLocalServer(..., workspace)`가 workspace cwd를 쓰는지 검증한다.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`: `opts.Dir`가 있으면 workspace보다 explicit `--dir` cwd를 우선하는지 검증한다.
- [ ] 새 테스트가 불필요하게 실제 codex/opencode binary, network server, 계정 상태에 의존하지 않도록 한다.
중간 검증:
```bash
go test -count=1 ./apps/node/internal/adapters/cli
```
기대 결과: CLI adapter package가 PASS하고 새 cwd tests가 실패 없이 실행된다.
### [REVIEW_API-2] Capture Required Edge-Node Diagnostic Evidence
문제: `./scripts/e2e-smoke.sh`는 보조 smoke이며 완료 기준이 아니다. node CLI adapter 실행 경로 변경은 testing rule상 repo 내부 edge-node 진단과 full-cycle 실제 구동 증거를 남겨야 한다.
해결 방법: 임시 edge/node config와 fake CLI profile을 사용해 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 별도 프로세스로 실행한다. Edge console에서 같은 session 메시지 2회와 command를 직접 보내고, edge 출력과 node 출력의 payload 내용/순서 일치를 검증한다. 기본 configs는 수정하지 않는다.
검증 항목:
- [ ] node registration 후 `/nodes` 결과가 edge 화면에 표시된다.
- [ ] 같은 session 메시지 2회가 각각 `[edge] sent`, `[node-*-event] start`, 비어 있지 않은 `[node-*-message]`, `[node-*-event] complete` 순서로 표시된다.
- [ ] node local `[node-message]` payload와 edge `[node-*-message]` payload가 run별로 내용/순서까지 일치한다.
- [ ] `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 결과가 edge 화면에 표시된다.
- [ ] 보조 `./scripts/e2e-smoke.sh` 결과와 repo 내부 edge-node 진단 결과를 서로 구분해 기록한다.
중간 검증:
```bash
# Terminal/session 1
IOP_EDGE_CONFIG=<temp-edge.yaml> ./scripts/dev/edge.sh
# Terminal/session 2
IOP_NODE_CONFIG=<temp-node.yaml> ./scripts/dev/node.sh
# Edge console transcript
/nodes
<message containing IOP_MANUAL_ONE token>
<message containing IOP_MANUAL_TWO token>
/capabilities
/transport
/sessions
/terminate-session
/exit
```
기대 결과: `agent-ops/skills/project/e2e-smoke/SKILL.md`의 repo 내부 edge-node 진단 판정 기준을 충족한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/codex_app_server_internal_test.go` | REVIEW_API-1 |
| `apps/node/internal/adapters/cli/opencode_sse_internal_test.go` | REVIEW_API-1 |
| `CODE_REVIEW-local-G07.md` | REVIEW_API-2 및 최종 구현 기록 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
./scripts/e2e-smoke.sh
git diff --check
```
모든 코드 변경과 검증 완료 후 반드시 `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,145 +0,0 @@
<!-- task=m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-13
task=m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `agent-cwd`: workspace-bound agent process 실행기가 `ExecutionSpec.Workspace`를 process working directory로 적용한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Apply Workspace To CLI Process Starts | [ ] |
## 구현 체크리스트
- [ ] predecessor `02+01_workspace_required_enforcement``complete.log`가 archive 위치에 있는지 확인한다.
- [ ] one-shot/codex-exec 공통 `executeCommand``ExecutionSpec.Workspace``exec.Cmd.Dir`로 적용하고, 빈 workspace에서는 기존 cwd 동작을 유지한다.
- [ ] persistent non-terminal과 terminal session start가 workspace를 process cwd로 적용한다.
- [ ] codex app-server와 opencode owned server process start가 workspace를 process cwd로 적용한다. opencode `--dir`가 명시된 경우 기존 explicit dir 우선순위를 유지한다.
- [ ] cwd 적용 테스트를 `apps/node/internal/adapters/cli``apps/node/internal/terminal`에 추가한다.
- [ ] `go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli``./scripts/e2e-smoke.sh`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_local_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_local_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/``agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/03+02_cli_agent_cwd/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md``CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `ExecutionSpec.Workspace`가 모든 CLI process start 경로에서 cwd로 적용되는지 확인한다.
- 빈 workspace에서 기존 cwd 동작이 유지되는지 확인한다.
- `session-workspace` 범위인 session key 변경을 이 plan에서 섞지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
(output)
```
### 최종 검증
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
(output)
$ ./scripts/e2e-smoke.sh
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus areas from the plan. |
| 검증 결과 | Implementing agent | Paste actual stdout/stderr for the commands above. |

View file

@ -213,7 +213,7 @@ func (c *CLI) Start(ctx context.Context) error {
for _, name := range names {
profile := c.profiles[name]
key := sessionKey{target: name, sessionID: runtime.DefaultSessionID}
sess, err := startProfileSession(ctx, key, profile, c.logger)
sess, err := startProfileSession(ctx, key, profile, "", c.logger)
if err != nil {
c.persistentExecutor.mu.Lock()
_ = c.persistentExecutor.stopAllSessions(context.Background())
@ -465,3 +465,7 @@ func closeProfileSession(_ context.Context, sess *profileSession) error {
}
return err
}
func workspaceDir(w string) string {
return strings.TrimSpace(w)
}

View file

@ -7,6 +7,7 @@ import (
"os"
"strings"
"testing"
"time"
"iop/apps/node/internal/adapters/cli/status"
"iop/apps/node/internal/runtime"
@ -677,3 +678,140 @@ func TestCloseProfileSession_PipeFallbackIdempotent(t *testing.T) {
t.Errorf("expected mockClose to be called 1 time, got %d", mockCloseCalled)
}
}
func TestCLIOneshotExecutorCwd(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"oneshot-pwd": {
Command: "pwd",
},
},
}, nil)
tmpDir := t.TempDir()
spec := runtime.ExecutionSpec{
Target: "oneshot-pwd",
Workspace: tmpDir,
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
var output string
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeDelta {
output += ev.Delta
}
}
resolved, err := os.Readlink(tmpDir)
if err != nil {
resolved = tmpDir
}
if !strings.Contains(output, tmpDir) && !strings.Contains(output, resolved) {
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
}
}
func TestCLIPersistentNonTerminalCwd(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"persistent-pwd": {
Command: "sh",
Persistent: true,
},
},
}, nil)
tmpDir := t.TempDir()
spec := runtime.ExecutionSpec{
Target: "persistent-pwd",
Workspace: tmpDir,
SessionID: "sess-cwd-nt",
SessionMode: runtime.SessionModeCreateIfMissing,
Input: map[string]any{"prompt": "pwd\n"},
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
defer func() {
_ = c.Stop(context.Background())
}()
var output string
success := false
resolved, err := os.Readlink(tmpDir)
if err != nil {
resolved = tmpDir
}
// Wait up to 2 seconds for output
for i := 0; i < 20; i++ {
output = ""
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeDelta {
output += ev.Delta
}
}
if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) {
success = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !success {
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
}
}
func TestCLIPersistentTerminalCwd(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"persistent-term-pwd": {
Command: "sh",
Persistent: true,
Terminal: true,
},
},
}, nil)
tmpDir := t.TempDir()
spec := runtime.ExecutionSpec{
Target: "persistent-term-pwd",
Workspace: tmpDir,
SessionID: "sess-cwd-term",
SessionMode: runtime.SessionModeCreateIfMissing,
Input: map[string]any{"prompt": "pwd"},
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
defer func() {
_ = c.Stop(context.Background())
}()
var output string
success := false
resolved, err := os.Readlink(tmpDir)
if err != nil {
resolved = tmpDir
}
// Wait up to 2 seconds for output
for i := 0; i < 20; i++ {
output = ""
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeDelta {
output += ev.Delta
}
}
if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) {
success = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !success {
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
}
}

View file

@ -311,7 +311,7 @@ func (e *codexAppServerExecutor) resolveCodexAppServerSession(ctx context.Contex
}
e.mu.Unlock()
proc, err := startCodexAppServerProc(ctx, profile)
proc, err := startCodexAppServerProc(ctx, profile, spec.Workspace)
if err != nil {
return nil, fmt.Errorf("cli adapter: start codex app-server: %w", err)
}
@ -380,12 +380,15 @@ func (e *codexAppServerExecutor) Stop(_ context.Context) error {
// --- process lifecycle ---
func startCodexAppServerProc(ctx context.Context, profile config.CLIProfileConf) (*codexAppServerProc, error) {
func startCodexAppServerProc(ctx context.Context, profile config.CLIProfileConf, workspace string) (*codexAppServerProc, error) {
args := append(append([]string{}, profile.Args...), "--stdio")
cmd := exec.Command(profile.Command, args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
if dir := workspaceDir(workspace); dir != "" {
cmd.Dir = dir
}
stdinPipe, err := cmd.StdinPipe()
if err != nil {

View file

@ -6,9 +6,12 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
@ -1046,3 +1049,49 @@ func TestCodexAppServerInit_ThreadStartHasParams(t *testing.T) {
t.Fatalf("thread/start 'params' must be a JSON object, got: %s", string(rawParams))
}
}
func TestCodexAppServerProcCwd(t *testing.T) {
tmpDir := t.TempDir()
workspace := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-codex-server.sh")
scriptContent := fmt.Sprintf(`#!/bin/sh
pwd > "%s/cwd.txt"
echo '{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}'
`, workspace)
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write fake script: %v", err)
}
profile := config.CLIProfileConf{
Command: scriptPath,
Args: []string{},
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
proc, err := startCodexAppServerProc(ctx, profile, workspace)
if err != nil {
t.Fatalf("startCodexAppServerProc: %v", err)
}
defer proc.close()
time.Sleep(150 * time.Millisecond)
markerPath := filepath.Join(workspace, "cwd.txt")
cwdBytes, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("failed to read marker file: %v", err)
}
cwd := strings.TrimSpace(string(cwdBytes))
resolvedWorkspace, err := os.Readlink(workspace)
if err != nil {
resolvedWorkspace = workspace
}
if cwd != workspace && cwd != resolvedWorkspace {
t.Errorf("expected process cwd to be %q or %q, got %q", workspace, resolvedWorkspace, cwd)
}
}

View file

@ -23,6 +23,9 @@ func (c *CLI) executeOneShot(ctx context.Context, spec runtime.ExecutionSpec, pr
func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, args []string, prompt string, sink runtime.EventSink) (string, error) {
cmd := exec.CommandContext(ctx, profile.Command, args...)
if dir := workspaceDir(spec.Workspace); dir != "" {
cmd.Dir = dir
}
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)

View file

@ -197,7 +197,7 @@ func (e *opencodeExecutor) resolveOpencodeSession(ctx context.Context, spec runt
var cmd *exec.Cmd
owned := false
if serverURL == "" {
url, started, err := startOpencodeLocalServer(ctx, profile, e.cli.logger)
url, started, err := startOpencodeLocalServer(ctx, profile, opts, spec.Workspace, e.cli.logger)
if err != nil {
return nil, fmt.Errorf("cli adapter: start opencode server: %w", err)
}
@ -268,12 +268,21 @@ func (e *opencodeExecutor) Stop(ctx context.Context) error {
var opencodeServerListenRE = regexp.MustCompile(`(?i)opencode server listening on (https?://\S+)`)
func startOpencodeLocalServer(ctx context.Context, profile config.CLIProfileConf, logger *zap.Logger) (string, *exec.Cmd, error) {
func startOpencodeLocalServer(ctx context.Context, profile config.CLIProfileConf, opts opencodeRunOpts, workspace string, logger *zap.Logger) (string, *exec.Cmd, error) {
args := []string{"serve", "--hostname", "127.0.0.1", "--port", "0"}
cmd := exec.Command(profile.Command, args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
var dir string
if opts.Dir != "" {
dir = opts.Dir
} else {
dir = workspace
}
if dir := workspaceDir(dir); dir != "" {
cmd.Dir = dir
}
stdout, err := cmd.StdoutPipe()
if err != nil {

View file

@ -1,8 +1,16 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"iop/packages/go/config"
)
func TestParseOpencodeRunArgs(t *testing.T) {
@ -203,3 +211,105 @@ func TestDecodeSSEDataLine(t *testing.T) {
}
}
}
func TestOpencodeLocalServerCwd_Workspace(t *testing.T) {
tmpDir := t.TempDir()
workspace := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-opencode-server.sh")
scriptContent := fmt.Sprintf(`#!/bin/sh
pwd > "%s/cwd.txt"
echo "opencode server listening on http://127.0.0.1:12345"
`, workspace)
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write fake script: %v", err)
}
profile := config.CLIProfileConf{
Command: scriptPath,
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
url, cmd, err := startOpencodeLocalServer(ctx, profile, opencodeRunOpts{}, workspace, nil)
if err != nil {
t.Fatalf("startOpencodeLocalServer failed: %v", err)
}
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
if url != "http://127.0.0.1:12345" {
t.Errorf("url got %q, want http://127.0.0.1:12345", url)
}
time.Sleep(100 * time.Millisecond)
markerPath := filepath.Join(workspace, "cwd.txt")
cwdBytes, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("failed to read marker file: %v", err)
}
cwd := strings.TrimSpace(string(cwdBytes))
resolvedWorkspace, err := os.Readlink(workspace)
if err != nil {
resolvedWorkspace = workspace
}
if cwd != workspace && cwd != resolvedWorkspace {
t.Errorf("expected process cwd to be %q or %q, got %q", workspace, resolvedWorkspace, cwd)
}
}
func TestOpencodeLocalServerCwd_DirOverride(t *testing.T) {
tmpDir := t.TempDir()
workspace := t.TempDir()
overrideDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-opencode-server.sh")
scriptContent := fmt.Sprintf(`#!/bin/sh
pwd > "%s/cwd.txt"
echo "opencode server listening on http://127.0.0.1:12345"
`, overrideDir)
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write fake script: %v", err)
}
profile := config.CLIProfileConf{
Command: scriptPath,
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
opts := opencodeRunOpts{Dir: overrideDir}
url, cmd, err := startOpencodeLocalServer(ctx, profile, opts, workspace, nil)
if err != nil {
t.Fatalf("startOpencodeLocalServer failed: %v", err)
}
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
if url != "http://127.0.0.1:12345" {
t.Errorf("url got %q, want http://127.0.0.1:12345", url)
}
time.Sleep(100 * time.Millisecond)
markerPath := filepath.Join(overrideDir, "cwd.txt")
cwdBytes, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("failed to read marker file: %v", err)
}
cwd := strings.TrimSpace(string(cwdBytes))
resolvedOverrideDir, err := os.Readlink(overrideDir)
if err != nil {
resolvedOverrideDir = overrideDir
}
if cwd != overrideDir && cwd != resolvedOverrideDir {
t.Errorf("expected process cwd to be %q or %q, got %q", overrideDir, resolvedOverrideDir, cwd)
}
}

View file

@ -321,7 +321,7 @@ func (e *persistentExecutor) resolveSession(ctx context.Context, spec runtime.Ex
if spec.SessionMode == runtime.SessionModeRequireExisting {
return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID)
}
sess, err := startProfileSession(ctx, key, profile, e.cli.logger)
sess, err := startProfileSession(ctx, key, profile, spec.Workspace, e.cli.logger)
if err != nil {
return nil, err
}
@ -329,7 +329,7 @@ func (e *persistentExecutor) resolveSession(ctx context.Context, spec runtime.Ex
return sess, nil
}
func startProfileSession(_ context.Context, key sessionKey, profile config.CLIProfileConf, logger *zap.Logger) (*profileSession, error) {
func startProfileSession(_ context.Context, key sessionKey, profile config.CLIProfileConf, workspace string, logger *zap.Logger) (*profileSession, error) {
if profile.Command == "" {
return nil, fmt.Errorf("target %q has no command", key.target)
}
@ -354,6 +354,7 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
Env: profile.Env,
Rows: terminalRows,
Cols: terminalCols,
Dir: workspaceDir(workspace),
}
core, err := terminal.StartSession(context.Background(), opts)
if err != nil {
@ -376,6 +377,9 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
if dir := workspaceDir(workspace); dir != "" {
cmd.Dir = dir
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)

View file

@ -24,6 +24,7 @@ type Options struct {
Env []string
Rows uint16
Cols uint16
Dir string
}
type Output struct {
@ -61,6 +62,9 @@ func StartSession(ctx context.Context, opts Options) (Session, error) {
if len(opts.Env) > 0 {
cmd.Env = append(cmd.Environ(), opts.Env...)
}
if opts.Dir != "" {
cmd.Dir = opts.Dir
}
rows := opts.Rows
if rows == 0 {
rows = 80

View file

@ -153,3 +153,46 @@ func TestTerminalSessionSignalAfterClose(t *testing.T) {
t.Error("expected Signal to reject signal after close")
}
}
func TestTerminalSessionCwd(t *testing.T) {
tmpDir := t.TempDir()
opts := terminal.Options{
Command: "sh",
Dir: tmpDir,
}
core, err := terminal.StartSession(context.Background(), opts)
if err != nil {
t.Fatalf("failed to start terminal session: %v", err)
}
defer core.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := core.WritePrompt(ctx, "pwd"); err != nil {
t.Fatalf("failed to write prompt: %v", err)
}
resolvedTmpDir, err := os.Readlink(tmpDir)
if err != nil {
resolvedTmpDir = tmpDir
}
var tail string
success := false
for {
select {
case <-ctx.Done():
t.Fatalf("timeout waiting for pwd output. tail was: %q", tail)
case <-time.After(100 * time.Millisecond):
tail = core.Snapshot().Tail
if strings.Contains(tail, tmpDir) || strings.Contains(tail, resolvedTmpDir) {
success = true
break
}
}
if success {
break
}
}
}